From 07d0344be7c4f2563eb9c4694e0a033d852f1f36 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 27 Sep 2026 19:30:28 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20src/components=20sorted=20into=20feature?= =?UTF-8?q?=20folders,=20the=20four=20largest=20files=20split,=20and=20the?= =?UTF-8?q?=20component=20tests=20mirror=20the=20new=20layout=20=E2=80=94?= =?UTF-8?q?=2048=20components=20sat=20in=20one=20flat=20folder,=20ChatModa?= =?UTF-8?q?l=20ran=20to=201301=20lines,=20DumpCreateModal=20to=20983,=20an?= =?UTF-8?q?d=20the=20tests=20were=20grouped=20by=20theme,=20so=20there=20w?= =?UTF-8?q?as=20no=20telling=20which=20component=20had=20none.=20Component?= =?UTF-8?q?s=20now=20live=20in=20category/,=20chat/,=20comment/,=20dump/?= =?UTF-8?q?=20(with=20dump/create/=20for=20the=20new-dump=20wizard),=20edi?= =?UTF-8?q?tor/,=20form/,=20layout/=20(header,=20page=20shell,=20floating?= =?UTF-8?q?=20buttons),=20player/,=20playlist/,=20profile/,=20social/=20(v?= =?UTF-8?q?ote,=20like,=20follow,=20the=20who-voted=20popover)=20and=20ui/?= =?UTF-8?q?;=20FeedTabBar=20and=20PresenceRow,=20used=20by=20the=20index?= =?UTF-8?q?=20page=20alone,=20move=20to=20pages/index/.=20ChatModal=20give?= =?UTF-8?q?s=20up=20ChatMessageItem,=20PendingItem,=20ChatEditBox=20and=20?= =?UTF-8?q?ReplyRef,=20the=20message=20limits=20and=20text=20helpers=20(ch?= =?UTF-8?q?at/message.ts),=20the=20day=20and=20clock=20labels=20(chat/time?= =?UTF-8?q?.ts)=20and=20the=20draft=20storage=20(utils/chatDraft.ts,=20nex?= =?UTF-8?q?t=20to=20dumpDraft.ts).=20DumpCreateModal=20gives=20up=20StepIn?= =?UTF-8?q?dicator,=20LocalFilePreview,=20TitleField=20and=20DuplicateNoti?= =?UTF-8?q?ce;=20CommentThread=20gives=20up=20CommentForm=20and=20CommentN?= =?UTF-8?q?ode;=20TextEditor=20gives=20up=20the=20emoji=20picker=20popup?= =?UTF-8?q?=20and=20its=20skin-tone=20storage;=20MediaPlayer=20gives=20up?= =?UTF-8?q?=20its=20icons=20(which=20FilePreview=20imported=20from=20it),?= =?UTF-8?q?=20Waveform,=20SeekBar=20and=20the=20remembered=20volume.=20New?= =?UTF-8?q?PlaylistForm,=20a=20button=20that=20opens=20a=20modal=20holding?= =?UTF-8?q?=20the=20form,=20is=20NewPlaylistButton=20now,=20and=20Thumbnai?= =?UTF-8?q?l,=20ThumbnailPlaceholder,=20FilePreview=20and=20RichContentCar?= =?UTF-8?q?d=20use=20named=20exports=20like=20everything=20else.=20The=20t?= =?UTF-8?q?ests=20are=20split=20one=20file=20per=20component=20under=20src?= =?UTF-8?q?/test/components//=20(and=20src/test/pages/index/),=20t?= =?UTF-8?q?he=20same=20625=20of=20them.=20No=20behaviour=20changes;=20the?= =?UTF-8?q?=20catalogues=20only=20move=20their=20source=20references.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 17 +- src/App.tsx | 6 +- .../{ => category}/CategoryManager.tsx | Bin 7010 -> 7027 bytes .../{ => category}/CategorySelect.tsx | 2 +- .../{ => category}/CategorySwitcher.tsx | 4 +- src/components/chat/ChatEditBox.tsx | 74 +++ src/components/chat/ChatMessageItem.tsx | 197 +++++++ src/components/{ => chat}/ChatModal.tsx | 501 +----------------- src/components/chat/PendingItem.tsx | 93 ++++ src/components/chat/ReplyRef.tsx | 36 ++ src/components/chat/message.ts | 26 + src/components/chat/time.ts | 39 ++ src/components/comment/CommentForm.tsx | 136 +++++ .../CommentNode.tsx} | 263 +-------- src/components/comment/CommentThread.tsx | 110 ++++ src/components/{ => dump}/DumpCard.tsx | 30 +- src/components/{ => dump}/FilePreview.tsx | 17 +- src/components/{ => dump}/JournalCard.tsx | 28 +- src/components/{ => dump}/RichContentCard.tsx | 10 +- .../{ => dump/create}/DumpCreateModal.tsx | 223 +------- .../dump/create/DuplicateNotice.tsx | 47 ++ .../dump/create/LocalFilePreview.tsx | 30 ++ src/components/dump/create/StepIndicator.tsx | 44 ++ src/components/dump/create/TitleField.tsx | 75 +++ src/components/editor/EmojiPickerPopup.tsx | 112 ++++ src/components/{ => editor}/Markdown.tsx | 0 .../{ => editor}/MentionDropdown.tsx | 4 +- src/components/{ => editor}/TextEditor.tsx | 111 +--- src/components/editor/skinTone.ts | 13 + src/components/{ => form}/FileDropZone.tsx | 2 +- src/components/form/FileField.tsx | 2 +- src/components/form/FormError.tsx | 2 +- src/components/form/RichTextField.tsx | 2 +- src/components/{ => layout}/AppHeader.tsx | 10 +- src/components/{ => layout}/ChatButton.tsx | 4 +- src/components/{ => layout}/ChatFab.tsx | 6 +- src/components/{ => layout}/DumpFab.tsx | 6 +- .../{ => layout}/NotificationBell.tsx | 2 +- src/components/{ => layout}/PageError.tsx | 2 +- src/components/{ => layout}/PageShell.tsx | 0 src/components/{ => layout}/SearchBar.tsx | 0 src/components/{ => layout}/UserMenu.tsx | 4 +- src/components/{ => player}/GlobalPlayer.tsx | 10 +- src/components/{ => player}/MediaPlayer.tsx | 193 +------ src/components/player/SeekBar.tsx | 38 ++ src/components/player/Waveform.tsx | 80 +++ src/components/player/icons.tsx | 32 ++ src/components/player/volume.ts | 33 ++ .../{ => playlist}/AddToPlaylistModal.tsx | 10 +- .../NewPlaylistButton.tsx} | 10 +- .../{ => playlist}/PlaylistCard.tsx | 10 +- .../{ => playlist}/PlaylistCreateForm.tsx | 14 +- .../PlaylistMembershipPanel.tsx | 2 +- .../{ => profile}/ChangePasswordModal.tsx | 8 +- .../{ => profile}/ProfileSubpageHeader.tsx | 4 +- src/components/{ => social}/FollowButton.tsx | 4 +- src/components/{ => social}/LikeButton.tsx | 2 +- .../{ => social}/UserListPopover.tsx | 8 +- src/components/{ => social}/VoteButton.tsx | 2 +- src/components/{ => ui}/Avatar.tsx | 4 +- src/components/{ => ui}/ConfirmModal.tsx | 2 +- src/components/{ => ui}/CountedInput.tsx | 2 +- src/components/{ => ui}/ErrorCard.tsx | 0 src/components/{ => ui}/ImagePicker.tsx | 0 src/components/{ => ui}/Modal.tsx | 2 +- src/components/{ => ui}/TabBar.tsx | 0 src/components/{ => ui}/Thumbnail.tsx | 6 +- .../{ => ui}/ThumbnailPlaceholder.tsx | 6 +- src/components/{ => ui}/Tooltip.tsx | 0 src/contexts/ChatProvider.tsx | 4 +- src/locales/en.po | 496 ++++++++--------- src/locales/fr.po | 496 ++++++++--------- src/pages/Dump.tsx | 24 +- src/pages/DumpEdit.tsx | 16 +- src/pages/Index.tsx | 6 +- src/pages/NotFound.tsx | 2 +- src/pages/Notifications.tsx | 6 +- src/pages/PlaylistDetail.tsx | 22 +- src/pages/ResetPassword.tsx | 2 +- src/pages/Search.tsx | 10 +- src/pages/UserDumps.tsx | 10 +- src/pages/UserLogin.tsx | 2 +- src/pages/UserPlaylists.tsx | 16 +- src/pages/UserPublicProfile.tsx | 30 +- src/pages/UserRegister.tsx | 4 +- src/pages/UserUpvoted.tsx | 8 +- .../index}/FeedTabBar.tsx | 10 +- src/pages/index/FollowedFeed.tsx | 6 +- src/pages/index/HotFeed.tsx | 4 +- src/pages/index/JournalFeed.tsx | 4 +- src/pages/index/NewFeed.tsx | 4 +- .../index}/PresenceRow.tsx | 6 +- src/test/components/buttons.test.tsx | 389 -------------- .../category/CategorySelect.test.tsx | 42 ++ .../category/CategorySwitcher.test.tsx | 64 +++ .../components/{ => chat}/ChatModal.test.tsx | 12 +- .../{ => comment}/CommentThread.test.tsx | 10 +- src/test/components/content.test.tsx | 416 --------------- src/test/components/dump/DumpCard.test.tsx | 119 +++++ src/test/components/dump/FilePreview.test.tsx | 119 +++++ src/test/components/dump/JournalCard.test.tsx | 105 ++++ .../components/dump/RichContentCard.test.tsx | 108 ++++ .../create}/DumpCreateModal.test.tsx | 8 +- src/test/components/editor/Markdown.test.tsx | 104 ++++ .../{ => editor}/TextEditor.test.tsx | 10 +- src/test/components/header.test.tsx | 498 ----------------- src/test/components/layout/AppHeader.test.tsx | 125 +++++ .../components/layout/ChatButton.test.tsx | 24 + src/test/components/layout/Fabs.test.tsx | 69 +++ .../layout/NotificationBell.test.tsx | 47 ++ src/test/components/layout/PageError.test.tsx | 13 + src/test/components/layout/SearchBar.test.tsx | 60 +++ src/test/components/layout/UserMenu.test.tsx | 38 ++ .../GlobalPlayer.test.tsx} | 190 +------ .../MediaPlayer.test.tsx} | 119 +---- .../components/playlist/PlaylistCard.test.tsx | 82 +++ .../components/social/FollowButton.test.tsx | 90 ++++ .../components/social/LikeButton.test.tsx | 40 ++ .../social/UserListPopover.test.tsx | 96 ++++ .../components/social/VoteButton.test.tsx | 78 +++ src/test/components/ui/Avatar.test.tsx | 47 ++ .../ConfirmModal.test.tsx} | 54 +- src/test/components/ui/CountedInput.test.tsx | 19 + src/test/components/ui/Modal.test.tsx | 59 +++ src/test/components/ui/TabBar.test.tsx | 22 + src/test/components/ui/Thumbnail.test.tsx | 56 ++ .../ui/ThumbnailPlaceholder.test.tsx | 43 ++ src/test/components/ui/Tooltip.test.tsx | 20 + src/test/pages/index/FeedTabBar.test.tsx | 39 ++ src/test/pages/index/PresenceRow.test.tsx | 52 ++ src/utils/chatDraft.ts | 43 ++ 131 files changed, 3893 insertions(+), 3596 deletions(-) rename src/components/{ => category}/CategoryManager.tsx (96%) rename src/components/{ => category}/CategorySelect.tsx (95%) rename src/components/{ => category}/CategorySwitcher.tsx (97%) create mode 100644 src/components/chat/ChatEditBox.tsx create mode 100644 src/components/chat/ChatMessageItem.tsx rename src/components/{ => chat}/ChatModal.tsx (66%) create mode 100644 src/components/chat/PendingItem.tsx create mode 100644 src/components/chat/ReplyRef.tsx create mode 100644 src/components/chat/message.ts create mode 100644 src/components/chat/time.ts create mode 100644 src/components/comment/CommentForm.tsx rename src/components/{CommentThread.tsx => comment/CommentNode.tsx} (56%) create mode 100644 src/components/comment/CommentThread.tsx rename src/components/{ => dump}/DumpCard.tsx (84%) rename src/components/{ => dump}/FilePreview.tsx (95%) rename src/components/{ => dump}/JournalCard.tsx (90%) rename src/components/{ => dump}/RichContentCard.tsx (95%) rename src/components/{ => dump/create}/DumpCreateModal.tsx (81%) create mode 100644 src/components/dump/create/DuplicateNotice.tsx create mode 100644 src/components/dump/create/LocalFilePreview.tsx create mode 100644 src/components/dump/create/StepIndicator.tsx create mode 100644 src/components/dump/create/TitleField.tsx create mode 100644 src/components/editor/EmojiPickerPopup.tsx rename src/components/{ => editor}/Markdown.tsx (100%) rename src/components/{ => editor}/MentionDropdown.tsx (93%) rename src/components/{ => editor}/TextEditor.tsx (86%) create mode 100644 src/components/editor/skinTone.ts rename src/components/{ => form}/FileDropZone.tsx (98%) rename src/components/{ => layout}/AppHeader.tsx (96%) rename src/components/{ => layout}/ChatButton.tsx (87%) rename src/components/{ => layout}/ChatFab.tsx (94%) rename src/components/{ => layout}/DumpFab.tsx (95%) rename src/components/{ => layout}/NotificationBell.tsx (97%) rename src/components/{ => layout}/PageError.tsx (91%) rename src/components/{ => layout}/PageShell.tsx (100%) rename src/components/{ => layout}/SearchBar.tsx (100%) rename src/components/{ => layout}/UserMenu.tsx (95%) rename src/components/{ => player}/GlobalPlayer.tsx (97%) rename src/components/{ => player}/MediaPlayer.tsx (67%) create mode 100644 src/components/player/SeekBar.tsx create mode 100644 src/components/player/Waveform.tsx create mode 100644 src/components/player/icons.tsx create mode 100644 src/components/player/volume.ts rename src/components/{ => playlist}/AddToPlaylistModal.tsx (89%) rename src/components/{NewPlaylistForm.tsx => playlist/NewPlaylistButton.tsx} (85%) rename src/components/{ => playlist}/PlaylistCard.tsx (92%) rename src/components/{ => playlist}/PlaylistCreateForm.tsx (89%) rename src/components/{ => playlist}/PlaylistMembershipPanel.tsx (97%) rename src/components/{ => profile}/ChangePasswordModal.tsx (95%) rename src/components/{ => profile}/ProfileSubpageHeader.tsx (89%) rename src/components/{ => social}/FollowButton.tsx (94%) rename src/components/{ => social}/LikeButton.tsx (97%) rename src/components/{ => social}/UserListPopover.tsx (97%) rename src/components/{ => social}/VoteButton.tsx (96%) rename src/components/{ => ui}/Avatar.tsx (93%) rename src/components/{ => ui}/ConfirmModal.tsx (96%) rename src/components/{ => ui}/CountedInput.tsx (90%) rename src/components/{ => ui}/ErrorCard.tsx (100%) rename src/components/{ => ui}/ImagePicker.tsx (100%) rename src/components/{ => ui}/Modal.tsx (96%) rename src/components/{ => ui}/TabBar.tsx (100%) rename src/components/{ => ui}/Thumbnail.tsx (92%) rename src/components/{ => ui}/ThumbnailPlaceholder.tsx (92%) rename src/components/{ => ui}/Tooltip.tsx (100%) rename src/{components => pages/index}/FeedTabBar.tsx (82%) rename src/{components => pages/index}/PresenceRow.tsx (93%) delete mode 100644 src/test/components/buttons.test.tsx create mode 100644 src/test/components/category/CategorySelect.test.tsx create mode 100644 src/test/components/category/CategorySwitcher.test.tsx rename src/test/components/{ => chat}/ChatModal.test.tsx (97%) rename src/test/components/{ => comment}/CommentThread.test.tsx (96%) delete mode 100644 src/test/components/content.test.tsx create mode 100644 src/test/components/dump/DumpCard.test.tsx create mode 100644 src/test/components/dump/FilePreview.test.tsx create mode 100644 src/test/components/dump/JournalCard.test.tsx create mode 100644 src/test/components/dump/RichContentCard.test.tsx rename src/test/components/{ => dump/create}/DumpCreateModal.test.tsx (98%) create mode 100644 src/test/components/editor/Markdown.test.tsx rename src/test/components/{ => editor}/TextEditor.test.tsx (97%) delete mode 100644 src/test/components/header.test.tsx create mode 100644 src/test/components/layout/AppHeader.test.tsx create mode 100644 src/test/components/layout/ChatButton.test.tsx create mode 100644 src/test/components/layout/Fabs.test.tsx create mode 100644 src/test/components/layout/NotificationBell.test.tsx create mode 100644 src/test/components/layout/PageError.test.tsx create mode 100644 src/test/components/layout/SearchBar.test.tsx create mode 100644 src/test/components/layout/UserMenu.test.tsx rename src/test/components/{player.test.tsx => player/GlobalPlayer.test.tsx} (50%) rename src/test/components/{media.test.tsx => player/MediaPlayer.test.tsx} (64%) create mode 100644 src/test/components/playlist/PlaylistCard.test.tsx create mode 100644 src/test/components/social/FollowButton.test.tsx create mode 100644 src/test/components/social/LikeButton.test.tsx create mode 100644 src/test/components/social/UserListPopover.test.tsx create mode 100644 src/test/components/social/VoteButton.test.tsx create mode 100644 src/test/components/ui/Avatar.test.tsx rename src/test/components/{Modal.test.tsx => ui/ConfirmModal.test.tsx} (54%) create mode 100644 src/test/components/ui/CountedInput.test.tsx create mode 100644 src/test/components/ui/Modal.test.tsx create mode 100644 src/test/components/ui/TabBar.test.tsx create mode 100644 src/test/components/ui/Thumbnail.test.tsx create mode 100644 src/test/components/ui/ThumbnailPlaceholder.test.tsx create mode 100644 src/test/components/ui/Tooltip.test.tsx create mode 100644 src/test/pages/index/FeedTabBar.test.tsx create mode 100644 src/test/pages/index/PresenceRow.test.tsx create mode 100644 src/utils/chatDraft.ts diff --git a/README.md b/README.md index fe27d632..e4d2ae9d 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,22 @@ api/ src/ # React frontend (Vite) config/ # API base URL, validation constants, feed tabs, upload limits pages/ # Route-level components - index/ # Feed views (hot, new, followed, journal) - components/ # Shared UI components + index/ # Feed views (hot, new, followed, journal), feed tab bar, presence row + components/ # Shared UI components, by feature + category/ # Category picker, switcher and manager + chat/ # The chat modal and its message rows + comment/ # Comment threads, nodes and forms + 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 diff --git a/src/App.tsx b/src/App.tsx index ae4e9fa9..205d2447 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,9 +21,9 @@ import { FollowProvider } from "./contexts/FollowProvider.tsx"; import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx"; import { ThemeProvider } from "./contexts/ThemeProvider.tsx"; import { ChatProvider } from "./contexts/ChatProvider.tsx"; -import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; -import { DumpFab } from "./components/DumpFab.tsx"; -import { ChatFab } from "./components/ChatFab.tsx"; +import { GlobalPlayer } from "./components/player/GlobalPlayer.tsx"; +import { DumpFab } from "./components/layout/DumpFab.tsx"; +import { ChatFab } from "./components/layout/ChatFab.tsx"; import "./App.css"; diff --git a/src/components/CategoryManager.tsx b/src/components/category/CategoryManager.tsx similarity index 96% rename from src/components/CategoryManager.tsx rename to src/components/category/CategoryManager.tsx index d2a6f21db28f22c8f2455e7c1a930af550d866b7..c240d55311ceaba15df445366ee09b39bc9eeaf9 100644 GIT binary patch delta 35 rcmaE4_StO0f{AlGCMz*&P4;B8m|Vwb&7!AYnmKtVWA0{ECR<4W1HlZF delta 30 mcmext_Q-6)f{BY;Cu=jRPYz`?o!rZ4G5I88?q+i)8%Y4pmkW~s diff --git a/src/components/CategorySelect.tsx b/src/components/category/CategorySelect.tsx similarity index 95% rename from src/components/CategorySelect.tsx rename to src/components/category/CategorySelect.tsx index 26aeaf87..fca16bad 100644 --- a/src/components/CategorySelect.tsx +++ b/src/components/category/CategorySelect.tsx @@ -1,5 +1,5 @@ import { Trans } from "@lingui/react/macro"; -import { useCategories } from "../hooks/useCategories.ts"; +import { useCategories } from "../../hooks/useCategories.ts"; /** * Multi-select of categories rendered as toggle chips. Renders nothing when no diff --git a/src/components/CategorySwitcher.tsx b/src/components/category/CategorySwitcher.tsx similarity index 97% rename from src/components/CategorySwitcher.tsx rename to src/components/category/CategorySwitcher.tsx index 57de15bf..9f8cf100 100644 --- a/src/components/CategorySwitcher.tsx +++ b/src/components/category/CategorySwitcher.tsx @@ -8,8 +8,8 @@ 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 { DEFAULT_FEED_TAB } from "../config/feedTabs.ts"; +import { useCategories } from "../../hooks/useCategories.ts"; +import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts"; /** * Header dropdown that switches the feed scope between "All" and a category, diff --git a/src/components/chat/ChatEditBox.tsx b/src/components/chat/ChatEditBox.tsx new file mode 100644 index 00000000..a2e3104a --- /dev/null +++ b/src/components/chat/ChatEditBox.tsx @@ -0,0 +1,74 @@ +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"; + +export interface ChatEditBoxProps { + initial: string; + onSave: (body: string) => void; + onCancel: () => void; +} + +export function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) { + const [draft, setDraft] = useState(initial); + const editorRef = useRef(null); + const boxRef = useRef(null); + + // Focus without letting the browser scroll the list mid-transition (the + // message box is still easing to its new height); once it has settled, bring + // the whole edit box, Save included, into view. + useEffect(() => { + editorRef.current?.focus({ atEnd: true, preventScroll: true }); + const box = boxRef.current; + if (!box) return; + const settle = parseFloat( + getComputedStyle(box).getPropertyValue("--dur-reveal"), + ) * 1000 || 250; + const id = setTimeout( + () => box.scrollIntoView({ block: "nearest", behavior: "smooth" }), + settle + 20, + ); + return () => clearTimeout(id); + }, []); + + const save = () => { + if (!sendable(draft)) return; + onSave(draft.trim()); + }; + + return ( +
+ { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + save(); + } + }} + /> +
+ + +
+
+ ); +} diff --git a/src/components/chat/ChatMessageItem.tsx b/src/components/chat/ChatMessageItem.tsx new file mode 100644 index 00000000..66572978 --- /dev/null +++ b/src/components/chat/ChatMessageItem.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +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 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"; + +export interface ChatMessageItemProps { + message: ChatMessage; + /** Continues the previous message's run: no avatar or author line. */ + grouped: boolean; + mentionsMe: boolean; + /** The replied-to message has been deleted since this one loaded. */ + replyDeleted: boolean; + canEdit: boolean; + canDelete: boolean; + flashed: boolean; + editing: boolean; + onStartEdit: (id: string) => void; + onEndEdit: () => void; + onEdit: (id: string, body: string) => void; + onDelete: (id: string) => void; + onReply: (message: ChatMessage) => void; + onJumpTo: (id: string) => void; +} + +export function ChatMessageItem( + { + message, + grouped, + mentionsMe, + replyDeleted, + canEdit, + canDelete, + flashed, + editing, + onStartEdit, + onEndEdit, + onEdit, + onDelete, + onReply, + onJumpTo, + }: ChatMessageItemProps, +) { + const bodyRef = useHeightTransition(editing); + const [confirmDelete, setConfirmDelete] = useState(false); + + const actions = !editing && ( + + + {canEdit && ( + + )} + {canDelete && ( + + )} + + ); + + const edited = message.updatedAt && ( + + + {grouped + ? edited + : edited {relativeTime(message.updatedAt)}} + + + ); + + return ( +
+ {grouped + ? ( + // The avatar column holds the time instead, shown on hover. + + + + + + ) + : ( + + )} +
+ {message.replyToId && ( + + )} + {!grouped && ( +
+ + {message.authorUsername} + + + + + {edited} + {actions} +
+ )} + + {editing + ? ( + { + if (body !== message.body) onEdit(message.id, body); + onEndEdit(); + }} + onCancel={onEndEdit} + /> + ) + : ( + + {message.body} + + )} + + {grouped && !editing && edited} +
+ {grouped && actions} + + {confirmDelete && ( + { + setConfirmDelete(false); + onDelete(message.id); + }} + onCancel={() => setConfirmDelete(false)} + /> + )} +
+ ); +} diff --git a/src/components/ChatModal.tsx b/src/components/chat/ChatModal.tsx similarity index 66% rename from src/components/ChatModal.tsx rename to src/components/chat/ChatModal.tsx index 10465ccf..77f71724 100644 --- a/src/components/ChatModal.tsx +++ b/src/components/chat/ChatModal.tsx @@ -9,37 +9,29 @@ import { } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Link } from "react-router"; -import { Modal } from "./Modal.tsx"; -import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx"; -import { Markdown } from "./Markdown.tsx"; -import { Avatar } from "./Avatar.tsx"; -import { ConfirmModal } from "./ConfirmModal.tsx"; -import { Tooltip } from "./Tooltip.tsx"; -import { useHeightTransition } from "../hooks/useHeightTransition.ts"; -import { useWS, useWSChat } from "../hooks/useWS.ts"; -import { useAuth } from "../hooks/useAuth.ts"; -import { can } from "../utils/permissions.ts"; -import { relativeTime } from "../utils/relativeTime.ts"; -import { markdownToPlainText, mentionsUser } from "../utils/chatText.ts"; -import { isEmojiOnly } from "../utils/editorText.ts"; -import { i18n } from "../i18n.ts"; -import { API_URL } from "../config/api.ts"; +import { Avatar } from "../ui/Avatar.tsx"; +import { Modal } from "../ui/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 { API_URL } from "../../config/api.ts"; import type { ChatReplyTarget, PendingChatMessage, -} from "../contexts/WSContext.ts"; +} from "../../contexts/WSContext.ts"; import { type ChatMessage, deserializeChatMessage, - type PublicUser, type RawChatMessage, -} from "../model.ts"; +} 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"; -// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts. -const MAX_CHAT_LENGTH = 2000; -// The counter only shows once a message is getting near the limit. -const COUNT_FROM = 1800; // Matches the server's default page size; a full page means there may be more. const PAGE_SIZE = 50; // Resync after a reconnect walks forward from the oldest loaded message in @@ -78,469 +70,6 @@ function compareMessages(a: ChatMessage, b: ChatMessage): number { (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); } -// What the server holds a message to: its length once trimmed. -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. -function messageTextClass(body: string): string { - return isEmojiOnly(body) - ? "chat-message-text chat-message-text--jumbo" - : "chat-message-text"; -} - -function snippet(body: string): string { - return markdownToPlainText(body, t`image`); -} - -// The draft and reply target outlive the chat — closing it, or reloading the -// tab, keeps a half-written message. -interface StoredDraft { - draft: string; - replyTarget: ChatReplyTarget | null; -} - -function draftKey(userId: string): string { - return `chat-draft:${userId}`; -} - -function readDraft(userId: string | undefined): StoredDraft { - if (userId) { - try { - const raw = sessionStorage.getItem(draftKey(userId)); - const parsed = raw ? JSON.parse(raw) : null; - if (parsed && typeof parsed.draft === "string") { - return { draft: parsed.draft, replyTarget: parsed.replyTarget ?? null }; - } - } catch { - // Blocked storage or a mangled entry: start empty. - } - } - return { draft: "", replyTarget: null }; -} - -function writeDraft(userId: string | undefined, value: StoredDraft): void { - if (!userId) return; - try { - if (value.draft || value.replyTarget) { - sessionStorage.setItem(draftKey(userId), JSON.stringify(value)); - } else { - sessionStorage.removeItem(draftKey(userId)); - } - } catch { - // The draft just won't survive a reload. - } -} - -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. -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. -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(); -} - -interface ChatEditBoxProps { - initial: string; - onSave: (body: string) => void; - onCancel: () => void; -} - -function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) { - const [draft, setDraft] = useState(initial); - const editorRef = useRef(null); - const boxRef = useRef(null); - - // Focus without letting the browser scroll the list mid-transition (the - // message box is still easing to its new height); once it has settled, bring - // the whole edit box, Save included, into view. - useEffect(() => { - editorRef.current?.focus({ atEnd: true, preventScroll: true }); - const box = boxRef.current; - if (!box) return; - const settle = parseFloat( - getComputedStyle(box).getPropertyValue("--dur-reveal"), - ) * 1000 || 250; - const id = setTimeout( - () => box.scrollIntoView({ block: "nearest", behavior: "smooth" }), - settle + 20, - ); - return () => clearTimeout(id); - }, []); - - const save = () => { - if (!sendable(draft)) return; - onSave(draft.trim()); - }; - - return ( -
- { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - save(); - } - }} - /> -
- - -
-
- ); -} - -interface ReplyRefProps { - replyToId: string; - author?: string; - body?: string; - onJumpTo: (id: string) => void; -} - -// Subtle one-line reference to the replied-to message; clicking jumps to it. -// Falls back to a muted note when the target was deleted. -function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) { - return ( - - ); -} - -interface ChatMessageItemProps { - message: ChatMessage; - /** Continues the previous message's run: no avatar or author line. */ - grouped: boolean; - mentionsMe: boolean; - /** The replied-to message has been deleted since this one loaded. */ - replyDeleted: boolean; - canEdit: boolean; - canDelete: boolean; - flashed: boolean; - editing: boolean; - onStartEdit: (id: string) => void; - onEndEdit: () => void; - onEdit: (id: string, body: string) => void; - onDelete: (id: string) => void; - onReply: (message: ChatMessage) => void; - onJumpTo: (id: string) => void; -} - -function ChatMessageItem( - { - message, - grouped, - mentionsMe, - replyDeleted, - canEdit, - canDelete, - flashed, - editing, - onStartEdit, - onEndEdit, - onEdit, - onDelete, - onReply, - onJumpTo, - }: ChatMessageItemProps, -) { - const bodyRef = useHeightTransition(editing); - const [confirmDelete, setConfirmDelete] = useState(false); - - const actions = !editing && ( - - - {canEdit && ( - - )} - {canDelete && ( - - )} - - ); - - const edited = message.updatedAt && ( - - - {grouped - ? edited - : edited {relativeTime(message.updatedAt)}} - - - ); - - return ( -
- {grouped - ? ( - // The avatar column holds the time instead, shown on hover. - - - - - - ) - : ( - - )} -
- {message.replyToId && ( - - )} - {!grouped && ( -
- - {message.authorUsername} - - - - - {edited} - {actions} -
- )} - - {editing - ? ( - { - if (body !== message.body) onEdit(message.id, body); - onEndEdit(); - }} - onCancel={onEndEdit} - /> - ) - : ( - - {message.body} - - )} - - {grouped && !editing && edited} -
- {grouped && actions} - - {confirmDelete && ( - { - setConfirmDelete(false); - onDelete(message.id); - }} - onCancel={() => setConfirmDelete(false)} - /> - )} -
- ); -} - -interface PendingItemProps { - pending: PendingChatMessage; - grouped: boolean; - user: PublicUser; - onRetry: (nonce: string) => void; - onDiscard: (nonce: string) => void; - onRestore: (pending: PendingChatMessage) => void; - onJumpTo: (id: string) => void; -} - -// A message on its way: dimmed while sending; if it fails, it says why and -// offers to try again, take it back into the composer, or drop it. -function PendingItem( - { pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }: - PendingItemProps, -) { - const failed = pending.status === "failed"; - return ( -
- {grouped ? : ( - - )} -
- {pending.replyTo && ( - - )} - {!grouped && ( -
- {user.username} -
- )} - - {pending.body} - -
- {failed - ? ( - <> - {pending.error} - - - - - ) - : Sending…} -
-
-
- ); -} - type Row = | { kind: "sep"; key: string; label: string } | { kind: "new"; key: string } diff --git a/src/components/chat/PendingItem.tsx b/src/components/chat/PendingItem.tsx new file mode 100644 index 00000000..240cc78e --- /dev/null +++ b/src/components/chat/PendingItem.tsx @@ -0,0 +1,93 @@ +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 type { PublicUser } from "../../model.ts"; +import { ReplyRef } from "./ReplyRef.tsx"; +import { messageTextClass } from "./message.ts"; + +export interface PendingItemProps { + pending: PendingChatMessage; + grouped: boolean; + user: PublicUser; + onRetry: (nonce: string) => void; + onDiscard: (nonce: string) => void; + onRestore: (pending: PendingChatMessage) => void; + onJumpTo: (id: string) => void; +} + +// A message on its way: dimmed while sending; if it fails, it says why and +// offers to try again, take it back into the composer, or drop it. +export function PendingItem( + { pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }: + PendingItemProps, +) { + const failed = pending.status === "failed"; + return ( +
+ {grouped ? : ( + + )} +
+ {pending.replyTo && ( + + )} + {!grouped && ( +
+ {user.username} +
+ )} + + {pending.body} + +
+ {failed + ? ( + <> + {pending.error} + + + + + ) + : Sending…} +
+
+
+ ); +} diff --git a/src/components/chat/ReplyRef.tsx b/src/components/chat/ReplyRef.tsx new file mode 100644 index 00000000..d29fd90a --- /dev/null +++ b/src/components/chat/ReplyRef.tsx @@ -0,0 +1,36 @@ +import { Trans } from "@lingui/react/macro"; +import { snippet } from "./message.ts"; + +export interface ReplyRefProps { + replyToId: string; + author?: string; + body?: string; + onJumpTo: (id: string) => void; +} + +// Subtle one-line reference to the replied-to message; clicking jumps to it. +// Falls back to a muted note when the target was deleted. +export function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) { + return ( + + ); +} diff --git a/src/components/chat/message.ts b/src/components/chat/message.ts new file mode 100644 index 00000000..bde86bab --- /dev/null +++ b/src/components/chat/message.ts @@ -0,0 +1,26 @@ +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`); +} diff --git a/src/components/chat/time.ts b/src/components/chat/time.ts new file mode 100644 index 00000000..a4982614 --- /dev/null +++ b/src/components/chat/time.ts @@ -0,0 +1,39 @@ +// 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(); +} diff --git a/src/components/comment/CommentForm.tsx b/src/components/comment/CommentForm.tsx new file mode 100644 index 00000000..196c4ebe --- /dev/null +++ b/src/components/comment/CommentForm.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import { Controller } 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"; + +export interface CommentFormProps { + /** Async submit; throw to surface a root error, resolve to clear/close. */ + onSubmit: (body: string) => Promise; + submitLabel: React.ReactNode; + pendingLabel: React.ReactNode; + errorTitle: string; + placeholder?: string; + defaultValue?: string; + onCancel?: () => void; + /** Top-level form: only show Cancel once there's text to discard. */ + cancelWhenEmpty?: boolean; + editorRef?: React.Ref; + /** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */ + layout?: "inline" | "top"; + leading?: React.ReactNode; + className?: string; +} + +/** + * Single-field comment editor (post / reply / edit). Wraps the shared + * react-hook-form lifecycle so the three call sites stay identical. + */ +export function CommentForm({ + onSubmit, + submitLabel, + pendingLabel, + errorTitle, + placeholder, + defaultValue = "", + onCancel, + cancelWhenEmpty, + editorRef, + layout = "inline", + leading, + className, +}: CommentFormProps) { + const form = useApiForm<{ body: string }>({ + defaultValues: { body: defaultValue }, + }); + const body = form.watch("body"); + const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX; + + const submit = form.submit(async ({ body }) => { + if (!body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX) return; + await onSubmit(body); + form.reset({ body: "" }); + }); + + const showCancel = onCancel && (!cancelWhenEmpty || body.trim().length > 0); + + const inner = ( + <> + ( + void submit()} + // Escape backs out of a reply or an edit; the top-level box has + // nothing to back out of. + onCancel={onCancel && !cancelWhenEmpty + ? () => { + form.reset({ body: "" }); + onCancel(); + } + : undefined} + placeholder={placeholder} + autoResize + rows={1} + maxLength={VALIDATION.COMMENT_BODY_MAX} + /> + )} + /> + +
+ + {submitLabel} + + {showCancel && ( + + )} +
+ + ); + + return ( + + {layout === "top" + ? ( +
+
+ {leading} +
{inner}
+
+
+ ) + : ( +
+ {inner} +
+ )} +
+ ); +} diff --git a/src/components/CommentThread.tsx b/src/components/comment/CommentNode.tsx similarity index 56% rename from src/components/CommentThread.tsx rename to src/components/comment/CommentNode.tsx index 20ada189..ffa41971 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/comment/CommentNode.tsx @@ -1,180 +1,29 @@ -import React, { useMemo, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import { Link } from "react-router"; -import { Controller } from "react-hook-form"; import { t } from "@lingui/core/macro"; -import { Plural, Trans } from "@lingui/react/macro"; -import { API_URL, VALIDATION } from "../config/api.ts"; -import { can } from "../utils/permissions.ts"; +import { Trans } from "@lingui/react/macro"; +import { API_URL } from "../../config/api.ts"; +import { can } from "../../utils/permissions.ts"; import type { Comment, CreateCommentRequest, RawComment, UpdateCommentRequest, User, -} from "../model.ts"; -import { deserializeComment } from "../model.ts"; -import { Avatar } from "./Avatar.tsx"; -import { Markdown } from "./Markdown.tsx"; -import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx"; -import { LikeButton } from "./LikeButton.tsx"; -import { relativeTime } from "../utils/relativeTime.ts"; -import { Tooltip } from "./Tooltip.tsx"; -import { ConfirmModal } from "./ConfirmModal.tsx"; -import { useHeightTransition } from "../hooks/useHeightTransition.ts"; -import { useWS } from "../hooks/useWS.ts"; -import { useAuth } from "../hooks/useAuth.ts"; -import { - expectOk, - FormError, - FormProvider, - SubmitButton, - useApiForm, -} from "./form/index.ts"; - -interface CommentFormProps { - /** Async submit; throw to surface a root error, resolve to clear/close. */ - onSubmit: (body: string) => Promise; - submitLabel: React.ReactNode; - pendingLabel: React.ReactNode; - errorTitle: string; - placeholder?: string; - defaultValue?: string; - onCancel?: () => void; - /** Top-level form: only show Cancel once there's text to discard. */ - cancelWhenEmpty?: boolean; - editorRef?: React.Ref; - /** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */ - layout?: "inline" | "top"; - leading?: React.ReactNode; - className?: string; -} - -/** - * Single-field comment editor (post / reply / edit). Wraps the shared - * react-hook-form lifecycle so the three call sites stay identical. - */ -function CommentForm({ - onSubmit, - submitLabel, - pendingLabel, - errorTitle, - placeholder, - defaultValue = "", - onCancel, - cancelWhenEmpty, - editorRef, - layout = "inline", - leading, - className, -}: CommentFormProps) { - const form = useApiForm<{ body: string }>({ - defaultValues: { body: defaultValue }, - }); - const body = form.watch("body"); - const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX; - - const submit = form.submit(async ({ body }) => { - if (!body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX) return; - await onSubmit(body); - form.reset({ body: "" }); - }); - - const showCancel = onCancel && (!cancelWhenEmpty || body.trim().length > 0); - - const inner = ( - <> - ( - void submit()} - // Escape backs out of a reply or an edit; the top-level box has - // nothing to back out of. - onCancel={onCancel && !cancelWhenEmpty - ? () => { - form.reset({ body: "" }); - onCancel(); - } - : undefined} - placeholder={placeholder} - autoResize - rows={1} - maxLength={VALIDATION.COMMENT_BODY_MAX} - /> - )} - /> - -
- - {submitLabel} - - {showCancel && ( - - )} -
- - ); - - return ( - - {layout === "top" - ? ( -
-
- {leading} -
{inner}
-
-
- ) - : ( -
- {inner} -
- )} -
- ); -} - -interface CommentThreadProps { - dumpId: string; - comments: Comment[]; - currentUser: User | null; - token: string | null; - onCommentCreated: (comment: Comment) => void; - onCommentDeleted: (commentId: string) => void; - onCommentUpdated: (comment: Comment) => void; -} - -function buildTree(comments: Comment[]): Map { - const map = new Map(); - for (const c of comments) { - const key = c.parentId ?? "root"; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(c); - } - return map; -} +} from "../../model.ts"; +import { deserializeComment } from "../../model.ts"; +import { Avatar } from "../ui/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 { CommentForm } from "./CommentForm.tsx"; interface CommentNodeProps { comment: Comment; @@ -188,7 +37,7 @@ interface CommentNodeProps { onCommentUpdated: (comment: Comment) => void; } -function CommentNode({ +export function CommentNode({ comment, tree, depth, @@ -454,77 +303,3 @@ function CommentNode({ ); } - -export function CommentThread({ - dumpId, - comments, - currentUser, - token, - onCommentCreated, - onCommentDeleted, - onCommentUpdated, -}: CommentThreadProps) { - const tree = useMemo(() => buildTree(comments), [comments]); - const roots = tree.get("root") ?? []; - const { authFetch } = useAuth(); - - async function handleTopLevelSubmit(body: string) { - const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, { - method: "POST", - body: JSON.stringify({ body } satisfies CreateCommentRequest), - }); - onCommentCreated(deserializeComment(await expectOk(res))); - } - - const visibleCount = comments.filter((c) => !c.deleted).length; - - return ( -
-

- -

- - {currentUser && ( - - - - } - onSubmit={handleTopLevelSubmit} - submitLabel={Post comment} - pendingLabel={Posting…} - errorTitle={t`Failed to post comment`} - placeholder={t`Add a comment…`} - onCancel={() => {}} - cancelWhenEmpty - /> - )} - - {roots.length > 0 && ( -
    - {roots.map((comment) => ( - - ))} -
- )} -
- ); -} diff --git a/src/components/comment/CommentThread.tsx b/src/components/comment/CommentThread.tsx new file mode 100644 index 00000000..509dc647 --- /dev/null +++ b/src/components/comment/CommentThread.tsx @@ -0,0 +1,110 @@ +import { useMemo } from "react"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { API_URL } from "../../config/api.ts"; +import type { + Comment, + CreateCommentRequest, + RawComment, + 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 { CommentForm } from "./CommentForm.tsx"; +import { CommentNode } from "./CommentNode.tsx"; + +interface CommentThreadProps { + dumpId: string; + comments: Comment[]; + currentUser: User | null; + token: string | null; + onCommentCreated: (comment: Comment) => void; + onCommentDeleted: (commentId: string) => void; + onCommentUpdated: (comment: Comment) => void; +} + +function buildTree(comments: Comment[]): Map { + const map = new Map(); + for (const c of comments) { + const key = c.parentId ?? "root"; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(c); + } + return map; +} + +export function CommentThread({ + dumpId, + comments, + currentUser, + token, + onCommentCreated, + onCommentDeleted, + onCommentUpdated, +}: CommentThreadProps) { + const tree = useMemo(() => buildTree(comments), [comments]); + const roots = tree.get("root") ?? []; + const { authFetch } = useAuth(); + + async function handleTopLevelSubmit(body: string) { + const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, { + method: "POST", + body: JSON.stringify({ body } satisfies CreateCommentRequest), + }); + onCommentCreated(deserializeComment(await expectOk(res))); + } + + const visibleCount = comments.filter((c) => !c.deleted).length; + + return ( +
+

+ +

+ + {currentUser && ( + + + + } + onSubmit={handleTopLevelSubmit} + submitLabel={Post comment} + pendingLabel={Posting…} + errorTitle={t`Failed to post comment`} + placeholder={t`Add a comment…`} + onCancel={() => {}} + cancelWhenEmpty + /> + )} + + {roots.length > 0 && ( +
    + {roots.map((comment) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/DumpCard.tsx b/src/components/dump/DumpCard.tsx similarity index 84% rename from src/components/DumpCard.tsx rename to src/components/dump/DumpCard.tsx index 48a59b8e..e5e784ab 100644 --- a/src/components/DumpCard.tsx +++ b/src/components/dump/DumpCard.tsx @@ -1,19 +1,23 @@ 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 { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; -import FilePreview from "./FilePreview.tsx"; -import RichContentCard from "./RichContentCard.tsx"; -import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx"; -import { VoteButton } from "./VoteButton.tsx"; -import { Markdown } from "./Markdown.tsx"; -import { Tooltip } from "./Tooltip.tsx"; +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 { + isDumpVisited, + isRecent, + markDumpVisited, +} from "../../utils/visited.ts"; +import { FilePreview } from "./FilePreview.tsx"; +import { RichContentCard } from "./RichContentCard.tsx"; +import { ThumbnailPlaceholder } from "../ui/ThumbnailPlaceholder.tsx"; +import { VoteButton } from "../social/VoteButton.tsx"; +import { Markdown } from "../editor/Markdown.tsx"; +import { Tooltip } from "../ui/Tooltip.tsx"; interface DumpCardProps { dump: Dump; diff --git a/src/components/FilePreview.tsx b/src/components/dump/FilePreview.tsx similarity index 95% rename from src/components/FilePreview.tsx rename to src/components/dump/FilePreview.tsx index a244f574..669298d4 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/dump/FilePreview.tsx @@ -1,14 +1,15 @@ 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, MediaPlayer } from "./MediaPlayer.tsx"; +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 { MediaPlayer } from "../player/MediaPlayer.tsx"; import { PlayerContext, type PlayerItem, PlayerTimeContext, -} from "../contexts/PlayerContext.ts"; +} from "../../contexts/PlayerContext.ts"; import { BAR_GAP, BAR_W, @@ -16,7 +17,7 @@ import { NUM_BARS, VIEWBOX_W, WAVEFORM_H, -} from "../utils/waveform.ts"; +} from "../../utils/waveform.ts"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -180,7 +181,7 @@ function mimeIcon(mime: string): string { return "📁"; } -export default function FilePreview( +export function FilePreview( { dump, compact = false, global: useGlobal = false }: FilePreviewProps, ) { const { current, playing, play, togglePlay } = useContext(PlayerContext); diff --git a/src/components/JournalCard.tsx b/src/components/dump/JournalCard.tsx similarity index 90% rename from src/components/JournalCard.tsx rename to src/components/dump/JournalCard.tsx index 39d8517b..1a6304d9 100644 --- a/src/components/JournalCard.tsx +++ b/src/components/dump/JournalCard.tsx @@ -1,25 +1,29 @@ 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 { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; +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 { + isDumpVisited, + isRecent, + markDumpVisited, +} from "../../utils/visited.ts"; import { hasQuote, hasThumbnail, type JournalShape, -} from "../utils/journalLayout.ts"; -import { VoteButton } from "./VoteButton.tsx"; -import { Markdown } from "./Markdown.tsx"; -import { Tooltip } from "./Tooltip.tsx"; -import Thumbnail from "./Thumbnail.tsx"; +} from "../../utils/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 { canPlayRichContent, usePlayRichContent, -} from "../hooks/usePlayRichContent.ts"; +} from "../../hooks/usePlayRichContent.ts"; export type { JournalShape }; diff --git a/src/components/RichContentCard.tsx b/src/components/dump/RichContentCard.tsx similarity index 95% rename from src/components/RichContentCard.tsx rename to src/components/dump/RichContentCard.tsx index eda231ff..9c9f71fa 100644 --- a/src/components/RichContentCard.tsx +++ b/src/components/dump/RichContentCard.tsx @@ -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 type { RichContent } from "../../model.ts"; +import { PlayerContext } from "../../contexts/PlayerContext.ts"; import { canPlayRichContent, usePlayRichContent, -} from "../hooks/usePlayRichContent.ts"; -import Thumbnail from "./Thumbnail.tsx"; +} from "../../hooks/usePlayRichContent.ts"; +import { Thumbnail } from "../ui/Thumbnail.tsx"; interface RichContentCardProps { richContent: RichContent; @@ -17,7 +17,7 @@ interface RichContentCardProps { dumpHref?: string; } -export default function RichContentCard( +export function RichContentCard( { richContent, compact = false, thumbnailOverrideUrl, dumpHref }: RichContentCardProps, ) { diff --git a/src/components/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx similarity index 81% rename from src/components/DumpCreateModal.tsx rename to src/components/dump/create/DumpCreateModal.tsx index f494b0ba..ce1428a5 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/dump/create/DumpCreateModal.tsx @@ -1,10 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { UseFormRegisterReturn } from "react-hook-form"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; -import { Plural, 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 type { CreateUrlDumpRequest, Dump, @@ -14,32 +13,30 @@ import type { RawDumpUrlMatch, RawPlaylistMembership, UrlPreviewResponse, -} from "../model.ts"; +} from "../../../model.ts"; import { deserializeDump, 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 { relativeTime } from "../utils/relativeTime.ts"; -import { titleFromFileName } from "../utils/format.ts"; +} 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 { clearDumpDraft, type DumpDraft, EMPTY_DUMP_DRAFT, loadDumpDraft, saveDumpDraft, -} from "../utils/dumpDraft.ts"; -import { MAX_FILE_SIZE } from "../config/upload.ts"; -import RichContentCard from "./RichContentCard.tsx"; -import { MediaPlayer } from "./MediaPlayer.tsx"; -import type { RichContent } from "../model.ts"; -import { Modal } from "./Modal.tsx"; -import type { TextEditorHandle } from "./TextEditor.tsx"; -import { CategorySelect } from "./CategorySelect.tsx"; -import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; +} from "../../../utils/dumpDraft.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 type { TextEditorHandle } from "../../editor/TextEditor.tsx"; +import { CategorySelect } from "../../category/CategorySelect.tsx"; +import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx"; import { expectOk, FileField, @@ -49,54 +46,15 @@ import { SubmitButton, useApiForm, VisibilityToggle, -} from "./form/index.ts"; - -/** - * Posting a dump walks three panels. The split is deliberate: categories and - * visibility used to sit behind a disclosure, where they read as optional and - * went untouched — they are neither, so "why & where" is a stop on the way to - * posting rather than something to go looking for. - */ -type Step = "what" | "details" | "playlist"; +} from "../../form/index.ts"; +import { DuplicateNotice } from "./DuplicateNotice.tsx"; +import { LocalFilePreview } from "./LocalFilePreview.tsx"; +import { type Step, StepIndicator } from "./StepIndicator.tsx"; +import { TitleField } from "./TitleField.tsx"; /** Stable empty result, so rendering doesn't churn on a fresh array each pass. */ const NO_DUPLICATES: DumpUrlMatch[] = []; -const STEPS: { id: Step; label: () => string }[] = [ - { id: "what", label: () => t`Link or file` }, - { id: "details", label: () => t`Why & where` }, - { id: "playlist", label: () => t`Playlists` }, -]; - -/** Where you are in the three panels — and, past the first, how far you got. */ -function StepIndicator({ current }: { current: Step }) { - const currentIndex = STEPS.findIndex((s) => s.id === current); - - return ( -
    - {STEPS.map((step, i) => { - const state = i < currentIndex - ? "done" - : i === currentIndex - ? "current" - : "todo"; - return ( -
  1. - - {step.label()} -
  2. - ); - })} -
- ); -} - type UrlPreview = | { status: "idle" } | { status: "loading" } @@ -119,145 +77,6 @@ function prefersNoAutoFocus(): boolean { return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false; } -function LocalFilePreview({ file }: { file: File }) { - const [src, setSrc] = useState(null); - - useEffect(() => { - const url = URL.createObjectURL(file); - // Blob URL lifecycle requires setState in effect — no synchronous alternative. - // eslint-disable-next-line react-hooks/set-state-in-effect - setSrc(url); - return () => { - URL.revokeObjectURL(url); - }; - }, [file]); - - if (!src) return null; - const mime = file.type; - if (mime.startsWith("image/")) { - return {file.name}; - } - if (mime.startsWith("video/")) { - return ; - } - if (mime.startsWith("audio/")) { - return ; - } - return null; -} - -/** - * Title row shared by both kinds of dump: the suggested title (scraped from the - * page, or derived from the filename) sits there read-only until the ✎ is used, - * which is enough to stop stray typing while keeping a correction one click - * away. Once edited, a reset offers the suggestion back. - */ -function TitleField({ - registration, - editing, - onEdit, - onReset, - disabled, -}: { - registration: UseFormRegisterReturn<"title">; - editing: boolean; - onEdit: () => void; - onReset?: () => void; - disabled: boolean; -}) { - const inputRef = useRef(null); - const { ref, ...titleReg } = registration; - - return ( -
- -
- { - ref(el); - inputRef.current = el; - }} - disabled={disabled || !editing} - maxLength={VALIDATION.DUMP_TITLE_MAX} - /> - {!editing && ( - - )} - {editing && onReset && ( - - )} -
-
- ); -} - -/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */ -function DuplicateNotice( - { matches, onNavigate }: { - matches: DumpUrlMatch[]; - onNavigate: () => void; - }, -) { - const [first, ...rest] = matches; - - return ( -
- -
-

- - Already dumped by {first.username} {relativeTime(first.createdAt)} - -

- - {first.title} - - {rest.length > 0 && ( -

- -

- )} -

- You can post it anyway. -

-
-
- ); -} - interface DumpCreateModalProps { onClose: () => void; initialUrl?: string; diff --git a/src/components/dump/create/DuplicateNotice.tsx b/src/components/dump/create/DuplicateNotice.tsx new file mode 100644 index 00000000..2fdc296f --- /dev/null +++ b/src/components/dump/create/DuplicateNotice.tsx @@ -0,0 +1,47 @@ +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"; + +/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */ +export function DuplicateNotice( + { matches, onNavigate }: { + matches: DumpUrlMatch[]; + onNavigate: () => void; + }, +) { + const [first, ...rest] = matches; + + return ( +
+ +
+

+ + Already dumped by {first.username} {relativeTime(first.createdAt)} + +

+ + {first.title} + + {rest.length > 0 && ( +

+ +

+ )} +

+ You can post it anyway. +

+
+
+ ); +} diff --git a/src/components/dump/create/LocalFilePreview.tsx b/src/components/dump/create/LocalFilePreview.tsx new file mode 100644 index 00000000..892a8ebd --- /dev/null +++ b/src/components/dump/create/LocalFilePreview.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import { MediaPlayer } from "../../player/MediaPlayer.tsx"; + +/** What a picked file will look like, before it's uploaded. */ +export function LocalFilePreview({ file }: { file: File }) { + const [src, setSrc] = useState(null); + + useEffect(() => { + const url = URL.createObjectURL(file); + // Blob URL lifecycle requires setState in effect — no synchronous alternative. + // eslint-disable-next-line react-hooks/set-state-in-effect + setSrc(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [file]); + + if (!src) return null; + const mime = file.type; + if (mime.startsWith("image/")) { + return {file.name}; + } + if (mime.startsWith("video/")) { + return ; + } + if (mime.startsWith("audio/")) { + return ; + } + return null; +} diff --git a/src/components/dump/create/StepIndicator.tsx b/src/components/dump/create/StepIndicator.tsx new file mode 100644 index 00000000..546cb54d --- /dev/null +++ b/src/components/dump/create/StepIndicator.tsx @@ -0,0 +1,44 @@ +import { t } from "@lingui/core/macro"; + +/** + * Posting a dump walks three panels. The split is deliberate: categories and + * visibility used to sit behind a disclosure, where they read as optional and + * went untouched — they are neither, so "why & where" is a stop on the way to + * posting rather than something to go looking for. + */ +export type Step = "what" | "details" | "playlist"; + +const STEPS: { id: Step; label: () => string }[] = [ + { id: "what", label: () => t`Link or file` }, + { id: "details", label: () => t`Why & where` }, + { id: "playlist", label: () => t`Playlists` }, +]; + +/** Where you are in the three panels — and, past the first, how far you got. */ +export function StepIndicator({ current }: { current: Step }) { + const currentIndex = STEPS.findIndex((s) => s.id === current); + + return ( +
    + {STEPS.map((step, i) => { + const state = i < currentIndex + ? "done" + : i === currentIndex + ? "current" + : "todo"; + return ( +
  1. + + {step.label()} +
  2. + ); + })} +
+ ); +} diff --git a/src/components/dump/create/TitleField.tsx b/src/components/dump/create/TitleField.tsx new file mode 100644 index 00000000..4ea7ab48 --- /dev/null +++ b/src/components/dump/create/TitleField.tsx @@ -0,0 +1,75 @@ +import { useRef } from "react"; +import type { UseFormRegisterReturn } from "react-hook-form"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { VALIDATION } from "../../../config/api.ts"; + +/** + * Title row shared by both kinds of dump: the suggested title (scraped from the + * page, or derived from the filename) sits there read-only until the ✎ is used, + * which is enough to stop stray typing while keeping a correction one click + * away. Once edited, a reset offers the suggestion back. + */ +export function TitleField({ + registration, + editing, + onEdit, + onReset, + disabled, +}: { + registration: UseFormRegisterReturn<"title">; + editing: boolean; + onEdit: () => void; + onReset?: () => void; + disabled: boolean; +}) { + const inputRef = useRef(null); + const { ref, ...titleReg } = registration; + + return ( +
+ +
+ { + ref(el); + inputRef.current = el; + }} + disabled={disabled || !editing} + maxLength={VALIDATION.DUMP_TITLE_MAX} + /> + {!editing && ( + + )} + {editing && onReset && ( + + )} +
+
+ ); +} diff --git a/src/components/editor/EmojiPickerPopup.tsx b/src/components/editor/EmojiPickerPopup.tsx new file mode 100644 index 00000000..079d9a7b --- /dev/null +++ b/src/components/editor/EmojiPickerPopup.tsx @@ -0,0 +1,112 @@ +import { + type CSSProperties, + type KeyboardEvent, + type Ref, + useEffect, +} from "react"; +import { + EmojiPicker, + type Locale as EmojiLocale, + useSkinTone, +} from "frimousse"; +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"; + +/** Remembers the skin tone picked in the selector for the next picker. */ +function SkinTonePersist() { + const [skinTone] = useSkinTone(); + useEffect(() => { + try { + localStorage.setItem(SKIN_TONE_KEY, skinTone); + } catch { + // Private mode: the tone lasts as long as the picker. + } + }, [skinTone]); + return null; +} + +interface EmojiPickerPopupProps { + floatRef: Ref; + searchRef: Ref; + viewportRef: Ref; + style: CSSProperties; + /** Inline, the query is typed in the textarea and the search only shows it; + * browsing, the search field takes it. */ + inline: boolean; + query: string; + onQueryChange: (query: string) => void; + onSelect: (emoji: string) => void; + onClose: () => void; + onKeyDownCapture: (e: KeyboardEvent) => void; +} + +/** The floating emoji picker the editor opens from its button or a `:query`. */ +export function EmojiPickerPopup( + { + floatRef, + searchRef, + viewportRef, + style, + inline, + query, + onQueryChange, + onSelect, + onClose, + onKeyDownCapture, + }: EmojiPickerPopupProps, +) { + const { i18n } = useLingui(); + return ( +
+ onSelect(e.emoji)} + locale={i18n.locale as EmojiLocale} + emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL} + skinTone={readSkinTone()} + > + +
+ onQueryChange(e.target.value)} + placeholder={t`Search emoji…`} + aria-label={t`Search emoji`} + readOnly={inline} + tabIndex={inline ? -1 : undefined} + /> + + +
+ + + Loading… + + + No emoji found. + + + +
+
+ ); +} diff --git a/src/components/Markdown.tsx b/src/components/editor/Markdown.tsx similarity index 100% rename from src/components/Markdown.tsx rename to src/components/editor/Markdown.tsx diff --git a/src/components/MentionDropdown.tsx b/src/components/editor/MentionDropdown.tsx similarity index 93% rename from src/components/MentionDropdown.tsx rename to src/components/editor/MentionDropdown.tsx index 334b44c6..cd228e12 100644 --- a/src/components/MentionDropdown.tsx +++ b/src/components/editor/MentionDropdown.tsx @@ -1,6 +1,6 @@ import { type CSSProperties, useLayoutEffect, useRef } from "react"; -import { Avatar } from "./Avatar.tsx"; -import type { UserResult } from "../hooks/useMentionAutocomplete.ts"; +import { Avatar } from "../ui/Avatar.tsx"; +import type { UserResult } from "../../hooks/useMentionAutocomplete.ts"; interface MentionDropdownProps { /** For the textarea's `aria-controls` / `aria-activedescendant`. */ diff --git a/src/components/TextEditor.tsx b/src/components/editor/TextEditor.tsx similarity index 86% rename from src/components/TextEditor.tsx rename to src/components/editor/TextEditor.tsx index ea709a47..ea45f0d2 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/editor/TextEditor.tsx @@ -9,22 +9,16 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { - EmojiPicker, - type Locale as EmojiLocale, - type SkinTone, - useSkinTone, -} from "frimousse"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { useLingui } from "@lingui/react"; -import { charCountClass } from "../utils/charCount.ts"; +import { charCountClass } from "../../utils/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 { API_URL } from "../config/api.ts"; +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 { API_URL } from "../../config/api.ts"; export interface TextEditorHandle { /** `atEnd` puts the caret after the last character (and scrolls the text @@ -59,29 +53,6 @@ interface TextEditorProps { "aria-describedby"?: string; } -const SKIN_TONE_KEY = "emoji-skin-tone"; - -function readSkinTone(): SkinTone { - try { - return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none"; - } catch { - return "none"; - } -} - -/** Remembers the skin tone picked in the selector for the next picker. */ -function SkinTonePersist() { - const [skinTone] = useSkinTone(); - useEffect(() => { - try { - localStorage.setItem(SKIN_TONE_KEY, skinTone); - } catch { - // Private mode: the tone lasts as long as the picker. - } - }, [skinTone]); - return null; -} - function hasFiles(dt: DataTransfer): boolean { return Array.from(dt.types).includes("Files"); } @@ -114,7 +85,6 @@ export const TextEditor = forwardRef( }, ref, ) { - const { i18n } = useLingui(); const textareaRef = useRef(null); const floatRef = useRef(null); const emojiButtonRef = useRef(null); @@ -577,10 +547,19 @@ export const TextEditor = forwardRef( document.body, )} {emojiOpen && createPortal( -
{ + closeEmoji(); + textareaRef.current?.focus(); + }} onKeyDownCapture={(e) => { if (e.key === "Escape") { e.preventDefault(); @@ -624,57 +603,7 @@ export const TextEditor = forwardRef( ); } }} - > - selectEmoji(e.emoji)} - locale={i18n.locale as EmojiLocale} - emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL} - skinTone={readSkinTone()} - > - -
- setEmojiQuery(e.target.value)} - placeholder={t`Search emoji…`} - aria-label={t`Search emoji`} - // Inline, the query is typed in the textarea; the field - // only shows it. - readOnly={emojiMode === "inline"} - tabIndex={emojiMode === "inline" ? -1 : undefined} - /> - - -
- - - Loading… - - - No emoji found. - - - -
-
, + />, document.body, )} diff --git a/src/components/editor/skinTone.ts b/src/components/editor/skinTone.ts new file mode 100644 index 00000000..d166b9d3 --- /dev/null +++ b/src/components/editor/skinTone.ts @@ -0,0 +1,13 @@ +// 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"; + } +} diff --git a/src/components/FileDropZone.tsx b/src/components/form/FileDropZone.tsx similarity index 98% rename from src/components/FileDropZone.tsx rename to src/components/form/FileDropZone.tsx index 4314a3e4..e6f78e6d 100644 --- a/src/components/FileDropZone.tsx +++ b/src/components/form/FileDropZone.tsx @@ -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/format.ts"; function fileIcon(mime: string): string { if (mime.startsWith("image/")) return "🖼"; diff --git a/src/components/form/FileField.tsx b/src/components/form/FileField.tsx index 2a40782e..1e4a929d 100644 --- a/src/components/form/FileField.tsx +++ b/src/components/form/FileField.tsx @@ -6,7 +6,7 @@ import { useFormContext, } from "react-hook-form"; -import { FileDropZone } from "../FileDropZone.tsx"; +import { FileDropZone } from "./FileDropZone.tsx"; interface FileFieldProps { name: Path; diff --git a/src/components/form/FormError.tsx b/src/components/form/FormError.tsx index ab9265f0..c4b96571 100644 --- a/src/components/form/FormError.tsx +++ b/src/components/form/FormError.tsx @@ -1,7 +1,7 @@ import { t } from "@lingui/core/macro"; import { useFormState } from "react-hook-form"; -import { ErrorCard } from "../ErrorCard.tsx"; +import { ErrorCard } from "../ui/ErrorCard.tsx"; /** * Renders the form's root error (set by {@link useApiForm}'s `submit` on a diff --git a/src/components/form/RichTextField.tsx b/src/components/form/RichTextField.tsx index b5477b88..39e8b69f 100644 --- a/src/components/form/RichTextField.tsx +++ b/src/components/form/RichTextField.tsx @@ -8,7 +8,7 @@ import { useFormContext, } from "react-hook-form"; -import { TextEditor, type TextEditorHandle } from "../TextEditor.tsx"; +import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; interface RichTextFieldProps { name: Path; diff --git a/src/components/AppHeader.tsx b/src/components/layout/AppHeader.tsx similarity index 96% rename from src/components/AppHeader.tsx rename to src/components/layout/AppHeader.tsx index 40e27615..acfec65c 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -2,16 +2,18 @@ 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/useAuth.ts"; +import { useWS } from "../../hooks/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/useDocumentTitle.ts"; const DumpCreateModal = lazy(() => - import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal })) + import("../dump/create/DumpCreateModal.tsx").then((m) => ({ + default: m.DumpCreateModal, + })) ); /** Carried in a search entry's history state: the history index of the page diff --git a/src/components/ChatButton.tsx b/src/components/layout/ChatButton.tsx similarity index 87% rename from src/components/ChatButton.tsx rename to src/components/layout/ChatButton.tsx index c2a6c12f..8ddc9aa1 100644 --- a/src/components/ChatButton.tsx +++ b/src/components/layout/ChatButton.tsx @@ -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/useWS.ts"; +import { useChat } from "../../hooks/useChat.ts"; /** Header chat button with an unread-message badge. Opens the app-wide chat. */ export function ChatButton() { diff --git a/src/components/ChatFab.tsx b/src/components/layout/ChatFab.tsx similarity index 94% rename from src/components/ChatFab.tsx rename to src/components/layout/ChatFab.tsx index 4e357e7e..4be59dea 100644 --- a/src/components/ChatFab.tsx +++ b/src/components/layout/ChatFab.tsx @@ -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/useAuth.ts"; +import { useWSChat } from "../../hooks/useWS.ts"; +import { useChat } from "../../hooks/useChat.ts"; /** * Floating chat button shown once the header has scrolled out of view, mirroring diff --git a/src/components/DumpFab.tsx b/src/components/layout/DumpFab.tsx similarity index 95% rename from src/components/DumpFab.tsx rename to src/components/layout/DumpFab.tsx index 9c8a49c5..f32860d0 100644 --- a/src/components/DumpFab.tsx +++ b/src/components/layout/DumpFab.tsx @@ -1,10 +1,12 @@ 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/useAuth.ts"; const DumpCreateModal = lazy(() => - import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal })) + import("../dump/create/DumpCreateModal.tsx").then((m) => ({ + default: m.DumpCreateModal, + })) ); /** diff --git a/src/components/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx similarity index 97% rename from src/components/NotificationBell.tsx rename to src/components/layout/NotificationBell.tsx index 6e45bc04..f63d5324 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/layout/NotificationBell.tsx @@ -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/useWS.ts"; export function NotificationBell() { const { unreadNotificationCount, lastNotification } = useWS(); diff --git a/src/components/PageError.tsx b/src/components/layout/PageError.tsx similarity index 91% rename from src/components/PageError.tsx rename to src/components/layout/PageError.tsx index 3f6139ff..3e8414b7 100644 --- a/src/components/PageError.tsx +++ b/src/components/layout/PageError.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { t } from "@lingui/core/macro"; import { PageShell } from "./PageShell.tsx"; -import { ErrorCard } from "./ErrorCard.tsx"; +import { ErrorCard } from "../ui/ErrorCard.tsx"; export function PageError( { title, message, actions }: { diff --git a/src/components/PageShell.tsx b/src/components/layout/PageShell.tsx similarity index 100% rename from src/components/PageShell.tsx rename to src/components/layout/PageShell.tsx diff --git a/src/components/SearchBar.tsx b/src/components/layout/SearchBar.tsx similarity index 100% rename from src/components/SearchBar.tsx rename to src/components/layout/SearchBar.tsx diff --git a/src/components/UserMenu.tsx b/src/components/layout/UserMenu.tsx similarity index 95% rename from src/components/UserMenu.tsx rename to src/components/layout/UserMenu.tsx index d6d36a86..6b24eb59 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -2,8 +2,8 @@ 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 "./Avatar.tsx"; -import type { User } from "../model.ts"; +import { Avatar } from "../ui/Avatar.tsx"; +import type { User } from "../../model.ts"; export function UserMenu({ user }: { user: User }) { const [open, setOpen] = useState(false); diff --git a/src/components/GlobalPlayer.tsx b/src/components/player/GlobalPlayer.tsx similarity index 97% rename from src/components/GlobalPlayer.tsx rename to src/components/player/GlobalPlayer.tsx index b62543bc..b815ee39 100644 --- a/src/components/GlobalPlayer.tsx +++ b/src/components/player/GlobalPlayer.tsx @@ -6,12 +6,12 @@ import { PlayerContext, type PlayerItem, playerItemKey, -} from "../contexts/PlayerContext.ts"; +} from "../../contexts/PlayerContext.ts"; import { MediaPlayer } from "./MediaPlayer.tsx"; -import { fmt } from "../utils/duration.ts"; -import Thumbnail from "./Thumbnail.tsx"; -import { useYouTubePlaylist } from "../hooks/useYouTubePlaylist.ts"; -import { embedListId, withJsApi } from "../utils/youtube.ts"; +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"; type EmbedItem = Extract; diff --git a/src/components/MediaPlayer.tsx b/src/components/player/MediaPlayer.tsx similarity index 67% rename from src/components/MediaPlayer.tsx rename to src/components/player/MediaPlayer.tsx index 99c2902d..a957b856 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/player/MediaPlayer.tsx @@ -1,189 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import { - BAR_GAP, - BAR_W, - extractPeaks, - NUM_BARS, - VIEWBOX_W, - WAVEFORM_H, -} from "../utils/waveform.ts"; -import { fmt } from "../utils/duration.ts"; +import { fmt } from "../../utils/duration.ts"; import { t } from "@lingui/core/macro"; - -export const IconPlay = () => ( - - - -); - -export const IconPause = () => ( - - - - -); - -const IconVolume = ({ muted }: { muted: boolean }) => ( - - {muted - ? ( - - ) - : ( - - )} - -); - -const IconFullscreen = () => ( - - - -); - -// ── Waveform ────────────────────────────────────────────────────────────────── - -function Waveform( - { src, current, duration, onSeek }: { - src: string; - current: number; - duration: number; - onSeek: (t: number) => void; - }, -) { - const [peaks, setPeaks] = useState(null); - - useEffect(() => { - let cancelled = false; - extractPeaks(src, NUM_BARS) - .then((p) => { - if (!cancelled) setPeaks(p); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [src]); - - const progress = duration > 0 ? current / duration : 0; - - const handleClick = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const ratio = Math.max( - 0, - Math.min(1, (e.clientX - rect.left) / rect.width), - ); - onSeek(ratio * duration); - }; - - if (!peaks) { - return ( -
-
-
- ); - } - - return ( - - {Array.from(peaks).map((p, i) => { - const barH = Math.max(p * WAVEFORM_H, 2); - const x = i * (BAR_W + BAR_GAP); - const y = (WAVEFORM_H - barH) / 2; - const played = i / NUM_BARS <= progress; - return ( - - ); - })} - - ); -} - -// ── Volume ──────────────────────────────────────────────────────────────────── -// 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. -} - -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. - } -} - -// ── Seek bar ────────────────────────────────────────────────────────────────── - -/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks - * can't be decoded (cross-origin streams) — where a waveform would be a lie. */ -function SeekBar( - { current, duration, onSeek, onDragChange, className }: { - current: number; - duration: number; - onSeek: (t: number) => void; - onDragChange: (dragging: boolean) => void; - className?: string; - }, -) { - const progress = duration > 0 ? current / duration : 0; - return ( -
-
- - onDragChange(true)} - onPointerUp={() => onDragChange(false)} - onPointerCancel={() => onDragChange(false)} - onChange={(e) => onSeek(Number(e.target.value))} - aria-label={t`Seek`} - /> -
- ); -} - -// ── MediaPlayer ─────────────────────────────────────────────────────────────── +import { IconFullscreen, IconPause, IconPlay, IconVolume } from "./icons.tsx"; +import { SeekBar } from "./SeekBar.tsx"; +import { Waveform } from "./Waveform.tsx"; +import { rememberVolume, storedVolume } from "./volume.ts"; const HIDE_DELAY = 2500; @@ -230,8 +51,8 @@ export function MediaPlayer( const [current, setCurrent] = useState(0); const [duration, setDuration] = useState(0); const [dragging, setDragging] = useState(false); - const [volume, setVolume] = useState(lastVolume); - const [muted, setMuted] = useState(lastMuted); + const [volume, setVolume] = useState(() => storedVolume().volume); + const [muted, setMuted] = useState(() => storedVolume().muted); const [controlsVisible, setControlsVisible] = useState(true); const hideTimer = useRef | null>(null); diff --git a/src/components/player/SeekBar.tsx b/src/components/player/SeekBar.tsx new file mode 100644 index 00000000..1885d77a --- /dev/null +++ b/src/components/player/SeekBar.tsx @@ -0,0 +1,38 @@ +import { t } from "@lingui/core/macro"; + +/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks + * can't be decoded (cross-origin streams) — where a waveform would be a lie. */ +export function SeekBar( + { current, duration, onSeek, onDragChange, className }: { + current: number; + duration: number; + onSeek: (t: number) => void; + onDragChange: (dragging: boolean) => void; + className?: string; + }, +) { + const progress = duration > 0 ? current / duration : 0; + return ( +
+
+ + onDragChange(true)} + onPointerUp={() => onDragChange(false)} + onPointerCancel={() => onDragChange(false)} + onChange={(e) => onSeek(Number(e.target.value))} + aria-label={t`Seek`} + /> +
+ ); +} diff --git a/src/components/player/Waveform.tsx b/src/components/player/Waveform.tsx new file mode 100644 index 00000000..40737d90 --- /dev/null +++ b/src/components/player/Waveform.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { + BAR_GAP, + BAR_W, + extractPeaks, + NUM_BARS, + VIEWBOX_W, + WAVEFORM_H, +} from "../../utils/waveform.ts"; + +export function Waveform( + { src, current, duration, onSeek }: { + src: string; + current: number; + duration: number; + onSeek: (t: number) => void; + }, +) { + const [peaks, setPeaks] = useState(null); + + useEffect(() => { + let cancelled = false; + extractPeaks(src, NUM_BARS) + .then((p) => { + if (!cancelled) setPeaks(p); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [src]); + + const progress = duration > 0 ? current / duration : 0; + + const handleClick = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width), + ); + onSeek(ratio * duration); + }; + + if (!peaks) { + return ( +
+
+
+ ); + } + + return ( + + {Array.from(peaks).map((p, i) => { + const barH = Math.max(p * WAVEFORM_H, 2); + const x = i * (BAR_W + BAR_GAP); + const y = (WAVEFORM_H - barH) / 2; + const played = i / NUM_BARS <= progress; + return ( + + ); + })} + + ); +} diff --git a/src/components/player/icons.tsx b/src/components/player/icons.tsx new file mode 100644 index 00000000..615cce74 --- /dev/null +++ b/src/components/player/icons.tsx @@ -0,0 +1,32 @@ +// Player control icons, shared by MediaPlayer and FilePreview. + +export const IconPlay = () => ( + + + +); + +export const IconPause = () => ( + + + + +); + +export const IconVolume = ({ muted }: { muted: boolean }) => ( + + {muted + ? ( + + ) + : ( + + )} + +); + +export const IconFullscreen = () => ( + + + +); diff --git a/src/components/player/volume.ts b/src/components/player/volume.ts new file mode 100644 index 00000000..cba1cfbb --- /dev/null +++ b/src/components/player/volume.ts @@ -0,0 +1,33 @@ +// 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 }; +} diff --git a/src/components/AddToPlaylistModal.tsx b/src/components/playlist/AddToPlaylistModal.tsx similarity index 89% rename from src/components/AddToPlaylistModal.tsx rename to src/components/playlist/AddToPlaylistModal.tsx index dab125cd..42516a78 100644 --- a/src/components/AddToPlaylistModal.tsx +++ b/src/components/playlist/AddToPlaylistModal.tsx @@ -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 type { PlaylistMembership, RawPlaylistMembership } from "../model.ts"; -import { deserializePlaylistMembership } from "../model.ts"; -import { Modal } from "./Modal.tsx"; +import { API_URL } from "../../config/api.ts"; +import { useAuth } from "../../hooks/useAuth.ts"; +import type { PlaylistMembership, RawPlaylistMembership } from "../../model.ts"; +import { deserializePlaylistMembership } from "../../model.ts"; +import { Modal } from "../ui/Modal.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; interface AddToPlaylistModalProps { diff --git a/src/components/NewPlaylistForm.tsx b/src/components/playlist/NewPlaylistButton.tsx similarity index 85% rename from src/components/NewPlaylistForm.tsx rename to src/components/playlist/NewPlaylistButton.tsx index e56dd961..37efc97e 100644 --- a/src/components/NewPlaylistForm.tsx +++ b/src/components/playlist/NewPlaylistButton.tsx @@ -1,22 +1,22 @@ 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 "./Modal.tsx"; +import type { Playlist } from "../../model.ts"; +import { Modal } from "../ui/Modal.tsx"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; -interface NewPlaylistFormProps { +interface NewPlaylistButtonProps { onCreated: (playlist: Playlist) => void; toggleLabel?: string; toggleClassName?: string; } -export function NewPlaylistForm( +export function NewPlaylistButton( { onCreated, toggleLabel, toggleClassName = "new-item-toggle", - }: NewPlaylistFormProps, + }: NewPlaylistButtonProps, ) { const [open, setOpen] = useState(false); diff --git a/src/components/PlaylistCard.tsx b/src/components/playlist/PlaylistCard.tsx similarity index 92% rename from src/components/PlaylistCard.tsx rename to src/components/playlist/PlaylistCard.tsx index ff10634d..92aeb580 100644 --- a/src/components/PlaylistCard.tsx +++ b/src/components/playlist/PlaylistCard.tsx @@ -1,15 +1,15 @@ 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 type { Playlist } from "../../model.ts"; +import { relativeTime } from "../../utils/relativeTime.ts"; +import { playlistImageUrl, playlistUrl } from "../../utils/urls.ts"; import { isPlaylistVisited, isRecent, markPlaylistVisited, -} from "../utils/visited.ts"; -import { Tooltip } from "./Tooltip.tsx"; +} from "../../utils/visited.ts"; +import { Tooltip } from "../ui/Tooltip.tsx"; interface PlaylistCardProps { playlist: Playlist; diff --git a/src/components/PlaylistCreateForm.tsx b/src/components/playlist/PlaylistCreateForm.tsx similarity index 89% rename from src/components/PlaylistCreateForm.tsx rename to src/components/playlist/PlaylistCreateForm.tsx index 97bf7494..664c9a6c 100644 --- a/src/components/PlaylistCreateForm.tsx +++ b/src/components/playlist/PlaylistCreateForm.tsx @@ -1,9 +1,13 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { API_URL, VALIDATION } from "../config/api.ts"; -import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts"; -import { deserializePlaylist } from "../model.ts"; -import { useAuth } from "../hooks/useAuth.ts"; +import { API_URL, VALIDATION } from "../../config/api.ts"; +import type { + CreatePlaylistRequest, + Playlist, + RawPlaylist, +} from "../../model.ts"; +import { deserializePlaylist } from "../../model.ts"; +import { useAuth } from "../../hooks/useAuth.ts"; import { expectOk, FormActions, @@ -14,7 +18,7 @@ import { TextField, useApiForm, VisibilityToggle, -} from "./form/index.ts"; +} from "../form/index.ts"; interface PlaylistCreateFormProps { /** If provided, the new playlist will have this dump added to it. */ diff --git a/src/components/PlaylistMembershipPanel.tsx b/src/components/playlist/PlaylistMembershipPanel.tsx similarity index 97% rename from src/components/PlaylistMembershipPanel.tsx rename to src/components/playlist/PlaylistMembershipPanel.tsx index 914d5977..fb878d0c 100644 --- a/src/components/PlaylistMembershipPanel.tsx +++ b/src/components/playlist/PlaylistMembershipPanel.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Trans } from "@lingui/react/macro"; -import type { PlaylistMembership } from "../model.ts"; +import type { PlaylistMembership } from "../../model.ts"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; interface PlaylistMembershipPanelProps { diff --git a/src/components/ChangePasswordModal.tsx b/src/components/profile/ChangePasswordModal.tsx similarity index 95% rename from src/components/ChangePasswordModal.tsx rename to src/components/profile/ChangePasswordModal.tsx index c00fdbce..1bcd64bf 100644 --- a/src/components/ChangePasswordModal.tsx +++ b/src/components/profile/ChangePasswordModal.tsx @@ -1,9 +1,9 @@ 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 "./Modal.tsx"; +import { API_URL, VALIDATION } from "../../config/api.ts"; +import { useAuth } from "../../hooks/useAuth.ts"; +import { Modal } from "../ui/Modal.tsx"; import { expectOk, FormActions, @@ -12,7 +12,7 @@ import { SubmitButton, TextField, useApiForm, -} from "./form/index.ts"; +} from "../form/index.ts"; interface ChangePasswordModalProps { onClose: () => void; diff --git a/src/components/ProfileSubpageHeader.tsx b/src/components/profile/ProfileSubpageHeader.tsx similarity index 89% rename from src/components/ProfileSubpageHeader.tsx rename to src/components/profile/ProfileSubpageHeader.tsx index 47a69818..b6b56277 100644 --- a/src/components/ProfileSubpageHeader.tsx +++ b/src/components/profile/ProfileSubpageHeader.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { Link } from "react-router"; -import type { PublicUser } from "../model.ts"; -import { Avatar } from "./Avatar.tsx"; +import type { PublicUser } from "../../model.ts"; +import { Avatar } from "../ui/Avatar.tsx"; interface ProfileSubpageHeaderProps { username: string; diff --git a/src/components/FollowButton.tsx b/src/components/social/FollowButton.tsx similarity index 94% rename from src/components/FollowButton.tsx rename to src/components/social/FollowButton.tsx index 4a72f483..ea7c639e 100644 --- a/src/components/FollowButton.tsx +++ b/src/components/social/FollowButton.tsx @@ -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/useAuth.ts"; +import { useFollows } from "../../hooks/useFollows.ts"; interface FollowUserButtonProps { targetUserId: string; diff --git a/src/components/LikeButton.tsx b/src/components/social/LikeButton.tsx similarity index 97% rename from src/components/LikeButton.tsx rename to src/components/social/LikeButton.tsx index f249d5c9..f06bec5c 100644 --- a/src/components/LikeButton.tsx +++ b/src/components/social/LikeButton.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { t } from "@lingui/core/macro"; -import { API_URL } from "../config/api.ts"; +import { API_URL } from "../../config/api.ts"; import { UserListPopover } from "./UserListPopover.tsx"; interface LikeButtonProps { diff --git a/src/components/UserListPopover.tsx b/src/components/social/UserListPopover.tsx similarity index 97% rename from src/components/UserListPopover.tsx rename to src/components/social/UserListPopover.tsx index 2217535f..a661b704 100644 --- a/src/components/UserListPopover.tsx +++ b/src/components/social/UserListPopover.tsx @@ -3,16 +3,16 @@ 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 "./Avatar.tsx"; -import { useAuth } from "../hooks/useAuth.ts"; -import { DEFAULT_PAGE_SIZE } from "../config/api.ts"; +import { Avatar } from "../ui/Avatar.tsx"; +import { useAuth } from "../../hooks/useAuth.ts"; +import { DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { deserializePublicUser, type PaginatedData, parseAPIResponse, type PublicUser, type RawPublicUser, -} from "../model.ts"; +} from "../../model.ts"; interface UserListPopoverProps { anchorRef: React.RefObject; diff --git a/src/components/VoteButton.tsx b/src/components/social/VoteButton.tsx similarity index 96% rename from src/components/VoteButton.tsx rename to src/components/social/VoteButton.tsx index 294c49d4..80d6e46e 100644 --- a/src/components/VoteButton.tsx +++ b/src/components/social/VoteButton.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { t } from "@lingui/core/macro"; -import { API_URL } from "../config/api.ts"; +import { API_URL } from "../../config/api.ts"; import { UserListPopover } from "./UserListPopover.tsx"; interface VoteButtonProps { diff --git a/src/components/Avatar.tsx b/src/components/ui/Avatar.tsx similarity index 93% rename from src/components/Avatar.tsx rename to src/components/ui/Avatar.tsx index ab8ab23c..6aefb544 100644 --- a/src/components/Avatar.tsx +++ b/src/components/ui/Avatar.tsx @@ -1,6 +1,6 @@ import { useContext, useState } from "react"; -import { API_URL } from "../config/api.ts"; -import { AvatarOverrideContext } from "../contexts/AvatarOverrideContext.ts"; +import { API_URL } from "../../config/api.ts"; +import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts"; interface AvatarProps { userId: string; diff --git a/src/components/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx similarity index 96% rename from src/components/ConfirmModal.tsx rename to src/components/ui/ConfirmModal.tsx index 22f2443e..60b19389 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -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/useScrollLock.ts"; interface ConfirmModalProps { message: string; diff --git a/src/components/CountedInput.tsx b/src/components/ui/CountedInput.tsx similarity index 90% rename from src/components/CountedInput.tsx rename to src/components/ui/CountedInput.tsx index e087670b..5e488e9b 100644 --- a/src/components/CountedInput.tsx +++ b/src/components/ui/CountedInput.tsx @@ -1,5 +1,5 @@ import type React from "react"; -import { charCountClass } from "../utils/charCount.ts"; +import { charCountClass } from "../../utils/charCount.ts"; interface CountedInputProps extends diff --git a/src/components/ErrorCard.tsx b/src/components/ui/ErrorCard.tsx similarity index 100% rename from src/components/ErrorCard.tsx rename to src/components/ui/ErrorCard.tsx diff --git a/src/components/ImagePicker.tsx b/src/components/ui/ImagePicker.tsx similarity index 100% rename from src/components/ImagePicker.tsx rename to src/components/ui/ImagePicker.tsx diff --git a/src/components/Modal.tsx b/src/components/ui/Modal.tsx similarity index 96% rename from src/components/Modal.tsx rename to src/components/ui/Modal.tsx index 77edb076..9e359dbb 100644 --- a/src/components/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -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/useScrollLock.ts"; interface ModalProps { title: string; diff --git a/src/components/TabBar.tsx b/src/components/ui/TabBar.tsx similarity index 100% rename from src/components/TabBar.tsx rename to src/components/ui/TabBar.tsx diff --git a/src/components/Thumbnail.tsx b/src/components/ui/Thumbnail.tsx similarity index 92% rename from src/components/Thumbnail.tsx rename to src/components/ui/Thumbnail.tsx index 55b21f83..9cfc289e 100644 --- a/src/components/Thumbnail.tsx +++ b/src/components/ui/Thumbnail.tsx @@ -1,6 +1,6 @@ import { type ComponentProps, useState } from "react"; -import { canProxyImage, proxyImageUrl } from "../utils/proxyImage.ts"; -import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx"; +import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts"; +import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx"; interface ThumbnailProps { /** Raw image URL. Proxying is decided here — don't pre-proxy it. */ @@ -28,7 +28,7 @@ interface ThumbnailProps { * Before this existed each of these failures ended at `display: none`, leaving * an empty framed box and no signal anywhere that a load had failed. */ -export default function Thumbnail( +export function Thumbnail( { src, alt = "", diff --git a/src/components/ThumbnailPlaceholder.tsx b/src/components/ui/ThumbnailPlaceholder.tsx similarity index 92% rename from src/components/ThumbnailPlaceholder.tsx rename to src/components/ui/ThumbnailPlaceholder.tsx index 06486819..c6907ddd 100644 --- a/src/components/ThumbnailPlaceholder.tsx +++ b/src/components/ui/ThumbnailPlaceholder.tsx @@ -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/proxyImage.ts"; +import { initialsFor, tintFor } from "../../utils/thumbnailTint.ts"; interface ThumbnailPlaceholderProps { /** The dumped URL — seeds the fallback hue and the initial. */ @@ -26,7 +26,7 @@ interface ThumbnailPlaceholderProps { * Fills whatever box it's placed in, so the same component serves the 36px * compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile. */ -export default function ThumbnailPlaceholder( +export function ThumbnailPlaceholder( { url, seed, accentColor, faviconUrl, siteName, glyph, className }: ThumbnailPlaceholderProps, ) { diff --git a/src/components/Tooltip.tsx b/src/components/ui/Tooltip.tsx similarity index 100% rename from src/components/Tooltip.tsx rename to src/components/ui/Tooltip.tsx diff --git a/src/contexts/ChatProvider.tsx b/src/contexts/ChatProvider.tsx index 04405fc0..8664ada2 100644 --- a/src/contexts/ChatProvider.tsx +++ b/src/contexts/ChatProvider.tsx @@ -11,7 +11,9 @@ import { useAuth } from "../hooks/useAuth.ts"; import { ChatContext, type ChatContextValue } from "./ChatContext.ts"; const ChatModal = lazy(() => - import("../components/ChatModal.tsx").then((m) => ({ default: m.ChatModal })) + import("../components/chat/ChatModal.tsx").then((m) => ({ + default: m.ChatModal, + })) ); /** diff --git a/src/locales/en.po b/src/locales/en.po index 5c855aae..9794b6f2 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -13,39 +13,39 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" -#: src/components/CommentThread.tsx:274 +#: src/components/comment/CommentNode.tsx:123 msgid "[deleted]" msgstr "[deleted]" #. placeholder {0}: dump.commentCount -#: src/components/DumpCard.tsx:112 -#: src/components/JournalCard.tsx:116 +#: src/components/dump/DumpCard.tsx:112 +#: src/components/dump/JournalCard.tsx:116 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" #. placeholder {0}: playlist.dumpCount -#: src/components/PlaylistCard.tsx:87 +#: src/components/playlist/PlaylistCard.tsx:87 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}" #. placeholder {0}: rest.length -#: src/components/DumpCreateModal.tsx:246 +#: src/components/dump/create/DuplicateNotice.tsx:34 msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" #. placeholder {0}: typingNames[0] #. placeholder {1}: typingNames[1] -#: src/components/ChatModal.tsx:1078 +#: src/components/chat/ChatModal.tsx:607 msgid "{0} and {1} are typing…" msgstr "{0} and {1} are typing…" #. placeholder {0}: typingNames[0] -#: src/components/ChatModal.tsx:1076 +#: src/components/chat/ChatModal.tsx:605 msgid "{0} is typing…" msgstr "{0} is typing…" #. placeholder {0}: rest.length -#: src/components/PresenceRow.tsx:66 +#: src/pages/index/PresenceRow.tsx:66 msgid "{0} more online: {restNames}" msgstr "{0} more online: {restNames}" @@ -59,15 +59,15 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores" msgid "{label} ({count})" msgstr "{label} ({count})" -#: src/components/ChatModal.tsx:1207 +#: src/components/chat/ChatModal.tsx:736 msgid "{unseenCount} new messages" msgstr "{unseenCount} new messages" -#: src/components/CommentThread.tsx:484 +#: src/components/comment/CommentThread.tsx:64 msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}" -#: src/components/DumpCreateModal.tsx:950 +#: src/components/dump/create/DumpCreateModal.tsx:769 #: src/pages/PlaylistDetail.tsx:607 #: src/pages/UserPublicProfile.tsx:819 msgid "← Back" @@ -89,7 +89,7 @@ msgstr "← Back to profile" msgid "+ Invite someone" msgstr "+ Invite someone" -#: src/components/PlaylistMembershipPanel.tsx:90 +#: src/components/playlist/PlaylistMembershipPanel.tsx:90 msgid "+ New playlist" msgstr "+ New playlist" @@ -143,7 +143,7 @@ msgstr "<0>{0} upvoted <1>{1}" msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" -#: src/components/ChatModal.tsx:1206 +#: src/components/chat/ChatModal.tsx:735 msgid "1 new message" msgstr "1 new message" @@ -159,12 +159,12 @@ msgstr "a post" msgid "Account" msgstr "Account" -#: src/components/CommentThread.tsx:504 +#: src/components/comment/CommentThread.tsx:84 msgid "Add a comment…" msgstr "Add a comment…" -#: src/components/CategoryManager.tsx:96 -#: src/components/CategoryManager.tsx:97 +#: src/components/category/CategoryManager.tsx:96 +#: src/components/category/CategoryManager.tsx:97 msgid "Add category" msgstr "Add category" @@ -172,8 +172,8 @@ msgstr "Add category" msgid "Add email…" msgstr "Add email…" -#: src/components/AddToPlaylistModal.tsx:59 -#: src/components/DumpCreateModal.tsx:671 +#: src/components/dump/create/DumpCreateModal.tsx:490 +#: src/components/playlist/AddToPlaylistModal.tsx:59 msgid "Add to playlist" msgstr "Add to playlist" @@ -181,14 +181,14 @@ msgstr "Add to playlist" msgid "Admin" msgstr "Admin" -#: src/components/CategorySwitcher.tsx:80 -#: src/components/CategorySwitcher.tsx:83 +#: src/components/category/CategorySwitcher.tsx:80 +#: src/components/category/CategorySwitcher.tsx:83 msgid "All" msgstr "All" #. placeholder {0}: first.username #. placeholder {1}: relativeTime(first.createdAt) -#: src/components/DumpCreateModal.tsx:233 +#: src/components/dump/create/DuplicateNotice.tsx:21 msgid "Already dumped by {0} {1}" msgstr "Already dumped by {0} {1}" @@ -201,7 +201,7 @@ msgid "Appearance" msgstr "Appearance" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/components/ChangePasswordModal.tsx:89 +#: src/components/profile/ChangePasswordModal.tsx:89 #: src/pages/ResetPassword.tsx:111 #: src/pages/UserRegister.tsx:149 msgid "At least {0} characters" @@ -225,13 +225,13 @@ msgstr "Back to login" msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." -#: src/components/CategoryManager.tsx:213 -#: src/components/CategoryManager.tsx:214 -#: src/components/ChatModal.tsx:235 -#: src/components/CommentThread.tsx:129 -#: src/components/ConfirmModal.tsx:47 -#: src/components/DumpCreateModal.tsx:960 +#: src/components/category/CategoryManager.tsx:213 +#: src/components/category/CategoryManager.tsx:214 +#: src/components/chat/ChatEditBox.tsx:69 +#: src/components/comment/CommentForm.tsx:108 +#: src/components/dump/create/DumpCreateModal.tsx:779 #: src/components/form/FormActions.tsx:32 +#: src/components/ui/ConfirmModal.tsx:47 #: src/pages/Dump.tsx:409 #: src/pages/DumpEdit.tsx:502 #: src/pages/PlaylistDetail.tsx:975 @@ -244,21 +244,21 @@ msgstr "Cancel" msgid "Cancel removal" msgstr "Cancel removal" -#: src/components/ChatModal.tsx:1267 -#: src/components/ChatModal.tsx:1268 +#: src/components/chat/ChatModal.tsx:796 +#: src/components/chat/ChatModal.tsx:797 msgid "Cancel reply" msgstr "Cancel reply" -#: src/components/AppHeader.tsx:128 +#: src/components/layout/AppHeader.tsx:128 msgid "Cancel search" msgstr "Cancel search" -#: src/components/CategorySelect.tsx:22 +#: src/components/category/CategorySelect.tsx:22 #: src/pages/UserPublicProfile.tsx:1423 msgid "Categories" msgstr "Categories" -#: src/components/CategorySwitcher.tsx:103 +#: src/components/category/CategorySwitcher.tsx:103 msgid "Category" msgstr "Category" @@ -266,8 +266,8 @@ msgstr "Category" msgid "Change avatar" msgstr "Change avatar" -#: src/components/ChangePasswordModal.tsx:54 -#: src/components/ChangePasswordModal.tsx:108 +#: src/components/profile/ChangePasswordModal.tsx:54 +#: src/components/profile/ChangePasswordModal.tsx:108 msgid "Change password" msgstr "Change password" @@ -275,19 +275,19 @@ msgstr "Change password" msgid "Change password…" msgstr "Change password…" -#: src/components/TextEditor.tsx:648 +#: src/components/editor/EmojiPickerPopup.tsx:85 msgid "Change skin tone" msgstr "Change skin tone" -#: src/components/ChatButton.tsx:17 -#: src/components/ChatFab.tsx:49 -#: src/components/ChatFab.tsx:50 -#: src/components/ChatModal.tsx:1167 +#: src/components/chat/ChatModal.tsx:696 +#: src/components/layout/ChatButton.tsx:17 +#: src/components/layout/ChatFab.tsx:49 +#: src/components/layout/ChatFab.tsx:50 msgid "Chat" msgstr "Chat" -#: src/components/ChatButton.tsx:16 -#: src/components/ChatFab.tsx:48 +#: src/components/layout/ChatButton.tsx:16 +#: src/components/layout/ChatFab.tsx:48 msgid "Chat ({unreadChatCount} unread)" msgstr "Chat ({unreadChatCount} unread)" @@ -295,28 +295,28 @@ msgstr "Chat ({unreadChatCount} unread)" msgid "Checking invite…" msgstr "Checking invite…" -#: src/components/ChangePasswordModal.tsx:63 -#: src/components/Modal.tsx:60 +#: src/components/profile/ChangePasswordModal.tsx:63 +#: src/components/ui/Modal.tsx:60 msgid "Close" msgstr "Close" -#: src/components/DumpCreateModal.tsx:702 +#: src/components/dump/create/DumpCreateModal.tsx:521 msgid "Close anyway" msgstr "Close anyway" -#: src/components/TextEditor.tsx:657 +#: src/components/editor/EmojiPickerPopup.tsx:90 msgid "Close emoji picker" msgstr "Close emoji picker" -#: src/components/GlobalPlayer.tsx:242 +#: src/components/player/GlobalPlayer.tsx:242 msgid "Close player" msgstr "Close player" -#: src/components/DumpCreateModal.tsx:692 +#: src/components/dump/create/DumpCreateModal.tsx:511 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Close without posting? The attached file won't be kept — everything you typed will." -#: src/components/GlobalPlayer.tsx:234 +#: src/components/player/GlobalPlayer.tsx:234 msgid "Collapse player" msgstr "Collapse player" @@ -324,7 +324,7 @@ msgstr "Collapse player" msgid "Color scheme" msgstr "Color scheme" -#: src/components/ChangePasswordModal.tsx:96 +#: src/components/profile/ChangePasswordModal.tsx:96 #: src/pages/ResetPassword.tsx:118 msgid "Confirm new password" msgstr "Confirm new password" @@ -341,7 +341,7 @@ msgstr "Copied!" msgid "Copy" msgstr "Copy" -#: src/components/ChangePasswordModal.tsx:105 +#: src/components/profile/ChangePasswordModal.tsx:105 msgid "Could not change password" msgstr "Could not change password" @@ -350,7 +350,7 @@ msgstr "Could not change password" msgid "Could not delete" msgstr "Could not delete" -#: src/components/UserListPopover.tsx:141 +#: src/components/social/UserListPopover.tsx:141 msgid "Could not load." msgstr "Could not load." @@ -358,19 +358,19 @@ msgstr "Could not load." msgid "Could not save" msgstr "Could not save" -#: src/components/DumpCreateModal.tsx:814 +#: src/components/dump/create/DumpCreateModal.tsx:633 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." -#: src/components/ChatModal.tsx:1093 +#: src/components/chat/ChatModal.tsx:622 msgid "Couldn't load the chat." msgstr "Couldn't load the chat." -#: src/components/PlaylistCreateForm.tsx:90 +#: src/components/playlist/PlaylistCreateForm.tsx:90 msgid "Create" msgstr "Create" -#: src/components/PlaylistCreateForm.tsx:90 +#: src/components/playlist/PlaylistCreateForm.tsx:90 msgid "Create & Add" msgstr "Create & Add" @@ -380,11 +380,11 @@ msgstr "Create & Add" msgid "Created ({0}{1})" msgstr "Created ({0}{1})" -#: src/components/PlaylistCreateForm.tsx:89 +#: src/components/playlist/PlaylistCreateForm.tsx:89 msgid "Creating…" msgstr "Creating…" -#: src/components/ChangePasswordModal.tsx:74 +#: src/components/profile/ChangePasswordModal.tsx:74 msgid "Current password" msgstr "Current password" @@ -396,24 +396,24 @@ msgstr "Dark" msgid "Default tab" msgstr "Default tab" -#: src/components/CategoryManager.tsx:224 -#: src/components/CategoryManager.tsx:225 -#: src/components/ChatModal.tsx:347 -#: src/components/ChatModal.tsx:348 -#: src/components/ChatModal.tsx:446 -#: src/components/CommentThread.tsx:399 -#: src/components/CommentThread.tsx:405 -#: src/components/ConfirmModal.tsx:17 +#: src/components/category/CategoryManager.tsx:224 +#: src/components/category/CategoryManager.tsx:225 +#: src/components/chat/ChatMessageItem.tsx:88 +#: src/components/chat/ChatMessageItem.tsx:89 +#: src/components/chat/ChatMessageItem.tsx:187 +#: src/components/comment/CommentNode.tsx:248 +#: src/components/comment/CommentNode.tsx:254 +#: src/components/ui/ConfirmModal.tsx:17 #: src/pages/PlaylistDetail.tsx:971 msgid "Delete" msgstr "Delete" -#: src/components/CategoryManager.tsx:241 +#: src/components/category/CategoryManager.tsx:241 msgid "Delete category" msgstr "Delete category" #. placeholder {0}: category.name -#: src/components/CategoryManager.tsx:240 +#: src/components/category/CategoryManager.tsx:240 msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Delete category \"{0}\"? This cannot be undone." @@ -427,13 +427,13 @@ msgstr "Delete dump" msgid "Delete failed ({0})" msgstr "Delete failed ({0})" -#: src/components/PlaylistCard.tsx:110 +#: src/components/playlist/PlaylistCard.tsx:110 #: src/pages/PlaylistDetail.tsx:799 #: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" msgstr "Delete playlist" -#: src/components/CommentThread.tsx:404 +#: src/components/comment/CommentNode.tsx:253 msgid "Delete this comment?" msgstr "Delete this comment?" @@ -441,7 +441,7 @@ msgstr "Delete this comment?" msgid "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone." -#: src/components/ChatModal.tsx:445 +#: src/components/chat/ChatMessageItem.tsx:186 msgid "Delete this message?" msgstr "Delete this message?" @@ -450,42 +450,42 @@ msgstr "Delete this message?" msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." -#: src/components/ChatModal.tsx:269 +#: src/components/chat/ReplyRef.tsx:31 msgid "deleted message" msgstr "deleted message" -#: src/components/PlaylistCreateForm.tsx:82 +#: src/components/playlist/PlaylistCreateForm.tsx:82 #: src/pages/PlaylistDetail.tsx:932 msgid "Description (optional)" msgstr "Description (optional)" -#: src/components/ChatModal.tsx:533 +#: src/components/chat/PendingItem.tsx:84 msgid "Discard" msgstr "Discard" -#: src/components/ChatModal.tsx:1171 +#: src/components/chat/ChatModal.tsx:700 msgid "Disconnected — reconnecting…" msgstr "Disconnected — reconnecting…" -#: src/components/ChatModal.tsx:1243 -#: src/components/ChatModal.tsx:1244 +#: src/components/chat/ChatModal.tsx:772 +#: src/components/chat/ChatModal.tsx:773 msgid "Dismiss" msgstr "Dismiss" -#: src/components/DumpCreateModal.tsx:753 +#: src/components/dump/create/DumpCreateModal.tsx:572 msgid "Done" msgstr "Done" #. placeholder {0}: dump.fileName -#: src/components/FilePreview.tsx:329 +#: src/components/dump/FilePreview.tsx:330 msgid "Download {0}" msgstr "Download {0}" -#: src/components/FileDropZone.tsx:29 +#: src/components/form/FileDropZone.tsx:29 msgid "Drop a file here" msgstr "Drop a file here" -#: src/components/DumpCreateModal.tsx:861 +#: src/components/dump/create/DumpCreateModal.tsx:680 msgid "Drop a file here, or paste one" msgstr "Drop a file here, or paste one" @@ -493,15 +493,15 @@ msgstr "Drop a file here, or paste one" msgid "Drop a replacement here" msgstr "Drop a replacement here" -#: src/components/AppHeader.tsx:168 +#: src/components/layout/AppHeader.tsx:168 msgid "Dump" msgstr "Dump" -#: src/components/DumpCreateModal.tsx:973 +#: src/components/dump/create/DumpCreateModal.tsx:792 msgid "Dump it" msgstr "Dump it" -#: src/components/DumpCreateModal.tsx:730 +#: src/components/dump/create/DumpCreateModal.tsx:549 msgid "Dumped!" msgstr "Dumped!" @@ -521,17 +521,17 @@ msgstr "Dumps ({0}{1})" msgid "Earlier" msgstr "Earlier" -#: src/components/ChatModal.tsx:336 -#: src/components/ChatModal.tsx:337 -#: src/components/ChatModal.tsx:526 -#: src/components/CommentThread.tsx:390 +#: src/components/chat/ChatMessageItem.tsx:77 +#: src/components/chat/ChatMessageItem.tsx:78 +#: src/components/chat/PendingItem.tsx:77 +#: src/components/comment/CommentNode.tsx:239 #: src/pages/Dump.tsx:529 #: src/pages/PlaylistDetail.tsx:662 msgid "Edit" msgstr "Edit" #. placeholder {0}: category.name -#: src/components/CategoryManager.tsx:76 +#: src/components/category/CategoryManager.tsx:76 msgid "Edit {0}" msgstr "Edit {0}" @@ -543,20 +543,20 @@ msgstr "Edit description" msgid "Edit email" msgstr "Edit email" -#: src/components/DumpCreateModal.tsx:197 +#: src/components/dump/create/TitleField.tsx:55 #: src/pages/Dump.tsx:425 msgid "Edit title" msgstr "Edit title" -#: src/components/ChatModal.tsx:360 +#: src/components/chat/ChatMessageItem.tsx:101 msgid "edited" msgstr "edited" #. placeholder {0}: relativeTime(comment.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt) -#: src/components/ChatModal.tsx:361 -#: src/components/CommentThread.tsx:339 +#: src/components/chat/ChatMessageItem.tsx:102 +#: src/components/comment/CommentNode.tsx:188 #: src/pages/Dump.tsx:468 #: src/pages/PlaylistDetail.tsx:701 msgid "edited {0}" @@ -565,8 +565,8 @@ msgstr "edited {0}" #. placeholder {0}: comment.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString() -#: src/components/ChatModal.tsx:357 -#: src/components/CommentThread.tsx:337 +#: src/components/chat/ChatMessageItem.tsx:98 +#: src/components/comment/CommentNode.tsx:186 #: src/pages/Dump.tsx:466 #: src/pages/PlaylistDetail.tsx:698 msgid "Edited {0}" @@ -589,19 +589,19 @@ msgstr "Email address" msgid "Enter a query to search." msgstr "Enter a query to search." -#: src/components/GlobalPlayer.tsx:234 +#: src/components/player/GlobalPlayer.tsx:234 msgid "Expand player" msgstr "Expand player" -#: src/components/CategoryManager.tsx:234 +#: src/components/category/CategoryManager.tsx:234 msgid "Failed to create category" msgstr "Failed to create category" -#: src/components/PlaylistCreateForm.tsx:87 +#: src/components/playlist/PlaylistCreateForm.tsx:87 msgid "Failed to create playlist" msgstr "Failed to create playlist" -#: src/components/CategoryManager.tsx:157 +#: src/components/category/CategoryManager.tsx:157 msgid "Failed to delete" msgstr "Failed to delete" @@ -622,15 +622,15 @@ msgstr "Failed to generate invite" msgid "Failed to load" msgstr "Failed to load" -#: src/components/DumpCreateModal.tsx:762 +#: src/components/dump/create/DumpCreateModal.tsx:581 msgid "Failed to post" msgstr "Failed to post" -#: src/components/CommentThread.tsx:503 +#: src/components/comment/CommentThread.tsx:83 msgid "Failed to post comment" msgstr "Failed to post comment" -#: src/components/CommentThread.tsx:425 +#: src/components/comment/CommentNode.tsx:274 msgid "Failed to post reply" msgstr "Failed to post reply" @@ -640,11 +640,11 @@ msgstr "Failed to post reply" msgid "Failed to save" msgstr "Failed to save" -#: src/components/CategoryManager.tsx:235 +#: src/components/category/CategoryManager.tsx:235 msgid "Failed to save category" msgstr "Failed to save category" -#: src/components/CommentThread.tsx:352 +#: src/components/comment/CommentNode.tsx:201 msgid "Failed to save edit" msgstr "Failed to save edit" @@ -660,24 +660,24 @@ msgstr "Failed to update role" msgid "Feeds" msgstr "Feeds" -#: src/components/DumpCreateModal.tsx:801 +#: src/components/dump/create/DumpCreateModal.tsx:620 msgid "Fetching preview…" msgstr "Fetching preview…" -#: src/components/DumpCreateModal.tsx:588 +#: src/components/dump/create/DumpCreateModal.tsx:407 msgid "File too large (max 50 MB)." msgstr "File too large (max 50 MB)." -#: src/components/FollowButton.tsx:37 -#: src/components/FollowButton.tsx:64 +#: src/components/social/FollowButton.tsx:37 +#: src/components/social/FollowButton.tsx:64 msgid "Follow" msgstr "Follow" -#: src/components/FollowButton.tsx:35 +#: src/components/social/FollowButton.tsx:35 msgid "Follow {targetUsername}" msgstr "Follow {targetUsername}" -#: src/components/FollowButton.tsx:62 +#: src/components/social/FollowButton.tsx:62 msgid "Follow playlist" msgstr "Follow playlist" @@ -689,7 +689,7 @@ msgstr "Follow some public playlists to see their dumps here." msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." -#: src/components/FeedTabBar.tsx:40 +#: src/pages/index/FeedTabBar.tsx:40 #: src/pages/UserPublicProfile.tsx:994 #: src/pages/UserPublicProfile.tsx:1364 msgid "Followed" @@ -705,8 +705,8 @@ msgstr "Followed ({0}{1})" msgid "Followed playlists" msgstr "Followed playlists" -#: src/components/FollowButton.tsx:37 -#: src/components/FollowButton.tsx:64 +#: src/components/social/FollowButton.tsx:37 +#: src/components/social/FollowButton.tsx:64 #: src/pages/UserPublicProfile.tsx:1103 msgid "Following" msgstr "Following" @@ -723,7 +723,7 @@ msgstr "From people" msgid "From playlists" msgstr "From playlists" -#: src/components/MediaPlayer.tsx:527 +#: src/components/player/MediaPlayer.tsx:348 msgid "Fullscreen" msgstr "Fullscreen" @@ -735,7 +735,7 @@ msgstr "Go home" msgid "Go to login" msgstr "Go to login" -#: src/components/FeedTabBar.tsx:36 +#: src/pages/index/FeedTabBar.tsx:36 #: src/pages/UserPublicProfile.tsx:1343 msgid "Hot" msgstr "Hot" @@ -744,7 +744,7 @@ msgstr "Hot" msgid "If that address is registered you'll receive a reset link shortly." msgstr "If that address is registered you'll receive a reset link shortly." -#: src/components/ChatModal.tsx:95 +#: src/components/chat/message.ts:25 msgid "image" msgstr "image" @@ -752,8 +752,8 @@ msgstr "image" msgid "In collections" msgstr "In collections" -#: src/components/TextEditor.tsx:533 -#: src/components/TextEditor.tsx:535 +#: src/components/editor/TextEditor.tsx:503 +#: src/components/editor/TextEditor.tsx:505 msgid "Insert emoji" msgstr "Insert emoji" @@ -779,12 +779,12 @@ msgstr "invited by" msgid "Invitees" msgstr "Invitees" -#: src/components/FeedTabBar.tsx:38 +#: src/pages/index/FeedTabBar.tsx:38 #: src/pages/UserPublicProfile.tsx:1357 msgid "Journal" msgstr "Journal" -#: src/components/DumpCreateModal.tsx:699 +#: src/components/dump/create/DumpCreateModal.tsx:518 msgid "Keep editing" msgstr "Keep editing" @@ -792,15 +792,15 @@ msgstr "Keep editing" msgid "Light" msgstr "Light" -#: src/components/LikeButton.tsx:31 +#: src/components/social/LikeButton.tsx:31 msgid "Like" msgstr "Like" -#: src/components/DumpCreateModal.tsx:776 +#: src/components/dump/create/DumpCreateModal.tsx:595 msgid "Link" msgstr "Link" -#: src/components/DumpCreateModal.tsx:66 +#: src/components/dump/create/StepIndicator.tsx:12 msgid "Link or file" msgstr "Link or file" @@ -808,17 +808,17 @@ msgstr "Link or file" msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" -#: src/components/AppHeader.tsx:203 +#: src/components/layout/AppHeader.tsx:203 msgid "Live updates unavailable." msgstr "Live updates unavailable." -#: src/components/UserListPopover.tsx:237 +#: src/components/social/UserListPopover.tsx:237 #: src/pages/Notifications.tsx:483 #: src/pages/UserPublicProfile.tsx:1140 msgid "Load more" msgstr "Load more" -#: src/components/ChatModal.tsx:1192 +#: src/components/chat/ChatModal.tsx:721 msgid "Load older messages" msgstr "Load older messages" @@ -827,7 +827,7 @@ msgstr "Load older messages" msgid "Loading dump…" msgstr "Loading dump…" -#: src/components/ChatModal.tsx:1085 +#: src/components/chat/ChatModal.tsx:614 msgid "Loading messages…" msgstr "Loading messages…" @@ -851,12 +851,12 @@ msgstr "Loading playlist…" msgid "Loading profile…" msgstr "Loading profile…" -#: src/components/CategoryManager.tsx:52 -#: src/components/ChatModal.tsx:1191 -#: src/components/PlaylistMembershipPanel.tsx:28 -#: src/components/TextEditor.tsx:669 -#: src/components/UserListPopover.tsx:198 -#: src/components/UserListPopover.tsx:236 +#: src/components/category/CategoryManager.tsx:52 +#: src/components/chat/ChatModal.tsx:720 +#: src/components/editor/EmojiPickerPopup.tsx:102 +#: src/components/playlist/PlaylistMembershipPanel.tsx:28 +#: src/components/social/UserListPopover.tsx:198 +#: src/components/social/UserListPopover.tsx:236 #: src/pages/index/FollowedFeed.tsx:88 #: src/pages/index/HotFeed.tsx:32 #: src/pages/index/JournalFeed.tsx:29 @@ -873,18 +873,18 @@ msgstr "Loading profile…" msgid "Loading…" msgstr "Loading…" -#: src/components/AppHeader.tsx:176 +#: src/components/layout/AppHeader.tsx:176 #: src/pages/UserLogin.tsx:35 #: src/pages/UserLogin.tsx:61 #: src/pages/UserLogin.tsx:80 msgid "Log in" msgstr "Log in" -#: src/components/LikeButton.tsx:32 +#: src/components/social/LikeButton.tsx:32 msgid "Log in to like" msgstr "Log in to like" -#: src/components/VoteButton.tsx:29 +#: src/components/social/VoteButton.tsx:29 msgid "Log in to vote" msgstr "Log in to vote" @@ -905,7 +905,7 @@ msgstr "Login failed" msgid "Manage" msgstr "Manage" -#: src/components/FileDropZone.tsx:140 +#: src/components/form/FileDropZone.tsx:140 msgid "Max 50 MB" msgstr "Max 50 MB" @@ -913,12 +913,12 @@ msgstr "Max 50 MB" msgid "Moderator" msgstr "Moderator" -#: src/components/MediaPlayer.tsx:500 +#: src/components/player/MediaPlayer.tsx:321 msgid "Mute" msgstr "Mute" -#: src/components/CategoryManager.tsx:180 -#: src/components/CategoryManager.tsx:181 +#: src/components/category/CategoryManager.tsx:180 +#: src/components/category/CategoryManager.tsx:181 msgid "Name" msgstr "Name" @@ -926,35 +926,35 @@ msgstr "Name" msgid "new" msgstr "new" -#: src/components/ChatModal.tsx:1120 -#: src/components/FeedTabBar.tsx:37 +#: src/components/chat/ChatModal.tsx:649 +#: src/pages/index/FeedTabBar.tsx:37 #: src/pages/UserPublicProfile.tsx:1350 msgid "New" msgstr "New" -#: src/components/DumpCreateModal.tsx:671 -#: src/components/DumpFab.tsx:65 -#: src/components/DumpFab.tsx:66 +#: src/components/dump/create/DumpCreateModal.tsx:490 +#: src/components/layout/DumpFab.tsx:65 +#: src/components/layout/DumpFab.tsx:66 #: src/pages/UserDumps.tsx:88 #: src/pages/UserPublicProfile.tsx:1469 msgid "New dump" msgstr "New dump" -#: src/components/ChangePasswordModal.tsx:82 +#: src/components/profile/ChangePasswordModal.tsx:82 #: src/pages/ResetPassword.tsx:103 msgid "New password" msgstr "New password" -#: src/components/NewPlaylistForm.tsx:33 -#: src/components/NewPlaylistForm.tsx:40 +#: src/components/playlist/NewPlaylistButton.tsx:33 +#: src/components/playlist/NewPlaylistButton.tsx:40 msgid "New playlist" msgstr "New playlist" -#: src/components/DumpCreateModal.tsx:972 +#: src/components/dump/create/DumpCreateModal.tsx:791 msgid "Next →" msgstr "Next →" -#: src/components/GlobalPlayer.tsx:224 +#: src/components/player/GlobalPlayer.tsx:224 msgid "Next track" msgstr "Next track" @@ -972,7 +972,7 @@ msgstr "No dumps match \"{q}\"." msgid "No dumps yet. Be the first!" msgstr "No dumps yet. Be the first!" -#: src/components/TextEditor.tsx:672 +#: src/components/editor/EmojiPickerPopup.tsx:105 msgid "No emoji found." msgstr "No emoji found." @@ -985,11 +985,11 @@ msgstr "No followed playlists yet." msgid "No invitees yet." msgstr "No invitees yet." -#: src/components/ChatModal.tsx:1104 +#: src/components/chat/ChatModal.tsx:633 msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" -#: src/components/UserListPopover.tsx:206 +#: src/components/social/UserListPopover.tsx:206 msgid "No one yet." msgstr "No one yet." @@ -997,7 +997,7 @@ msgstr "No one yet." msgid "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"." -#: src/components/PlaylistMembershipPanel.tsx:34 +#: src/components/playlist/PlaylistMembershipPanel.tsx:34 #: src/pages/UserPlaylists.tsx:415 #: src/pages/UserPublicProfile.tsx:1074 msgid "No playlists yet." @@ -1023,14 +1023,14 @@ msgstr "Not following anyone yet." msgid "Nothing here yet." msgstr "Nothing here yet." -#: src/components/NotificationBell.tsx:42 +#: src/components/layout/NotificationBell.tsx:42 #: src/pages/Notifications.tsx:269 #: src/pages/Notifications.tsx:395 #: src/pages/UserPublicProfile.tsx:1372 msgid "Notifications" msgstr "Notifications" -#: src/components/NotificationBell.tsx:41 +#: src/components/layout/NotificationBell.tsx:41 msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} unread)" @@ -1042,20 +1042,20 @@ msgstr "Off" msgid "On" msgstr "On" -#: src/components/TextEditor.tsx:347 +#: src/components/editor/TextEditor.tsx:317 msgid "Only images can be dropped here." msgstr "Only images can be dropped here." #. placeholder {0}: richContent.siteName -#: src/components/RichContentCard.tsx:101 +#: src/components/dump/RichContentCard.tsx:101 msgid "Open on {0}" msgstr "Open on {0}" -#: src/components/RichContentCard.tsx:102 +#: src/components/dump/RichContentCard.tsx:102 msgid "Open source" msgstr "Open source" -#: src/components/FileDropZone.tsx:134 +#: src/components/form/FileDropZone.tsx:134 msgid "or <0>browse files" msgstr "or <0>browse files" @@ -1073,7 +1073,7 @@ msgstr "Password" msgid "Password (min. {0} characters)" msgstr "Password (min. {0} characters)" -#: src/components/ChangePasswordModal.tsx:59 +#: src/components/profile/ChangePasswordModal.tsx:59 msgid "Password changed successfully." msgstr "Password changed successfully." @@ -1081,33 +1081,33 @@ msgstr "Password changed successfully." msgid "Password updated" msgstr "Password updated" -#: src/components/ChangePasswordModal.tsx:102 +#: src/components/profile/ChangePasswordModal.tsx:102 #: src/pages/ResetPassword.tsx:123 msgid "Passwords do not match" msgstr "Passwords do not match" -#: src/components/DumpCreateModal.tsx:794 +#: src/components/dump/create/DumpCreateModal.tsx:613 msgid "Paste a link…" msgstr "Paste a link…" -#: src/components/FilePreview.tsx:114 -#: src/components/FilePreview.tsx:281 -#: src/components/MediaPlayer.tsx:484 -#: src/components/RichContentCard.tsx:83 +#: src/components/dump/FilePreview.tsx:115 +#: src/components/dump/FilePreview.tsx:282 +#: src/components/dump/RichContentCard.tsx:83 +#: src/components/player/MediaPlayer.tsx:305 msgid "Pause" msgstr "Pause" -#: src/components/DumpCreateModal.tsx:711 +#: src/components/dump/create/DumpCreateModal.tsx:530 msgid "Picked up where you left off." msgstr "Picked up where you left off." -#: src/components/FilePreview.tsx:114 -#: src/components/FilePreview.tsx:225 -#: src/components/FilePreview.tsx:246 -#: src/components/FilePreview.tsx:281 -#: src/components/MediaPlayer.tsx:484 -#: src/components/RichContentCard.tsx:83 -#: src/components/RichContentCard.tsx:129 +#: src/components/dump/FilePreview.tsx:115 +#: src/components/dump/FilePreview.tsx:226 +#: src/components/dump/FilePreview.tsx:247 +#: src/components/dump/FilePreview.tsx:282 +#: src/components/dump/RichContentCard.tsx:83 +#: src/components/dump/RichContentCard.tsx:129 +#: src/components/player/MediaPlayer.tsx:305 msgid "Play" msgstr "Play" @@ -1115,9 +1115,9 @@ msgstr "Play" msgid "Playlist title" msgstr "Playlist title" -#: src/components/AppHeader.tsx:149 -#: src/components/DumpCreateModal.tsx:68 -#: src/components/UserMenu.tsx:62 +#: src/components/dump/create/StepIndicator.tsx:14 +#: src/components/layout/AppHeader.tsx:149 +#: src/components/layout/UserMenu.tsx:62 #: src/pages/Search.tsx:191 #: src/pages/UserPlaylists.tsx:382 #: src/pages/UserPublicProfile.tsx:993 @@ -1130,28 +1130,28 @@ msgstr "Playlists" msgid "Playlists ({0}{1})" msgstr "Playlists ({0}{1})" -#: src/components/CommentThread.tsx:501 +#: src/components/comment/CommentThread.tsx:81 msgid "Post comment" msgstr "Post comment" -#: src/components/CommentThread.tsx:423 +#: src/components/comment/CommentNode.tsx:272 msgid "Post reply" msgstr "Post reply" -#: src/components/CommentThread.tsx:424 -#: src/components/CommentThread.tsx:502 -#: src/components/DumpCreateModal.tsx:966 +#: src/components/comment/CommentNode.tsx:273 +#: src/components/comment/CommentThread.tsx:82 +#: src/components/dump/create/DumpCreateModal.tsx:785 msgid "Posting…" msgstr "Posting…" -#: src/components/GlobalPlayer.tsx:208 +#: src/components/player/GlobalPlayer.tsx:208 msgid "Previous track" msgstr "Previous track" -#: src/components/DumpCard.tsx:121 -#: src/components/JournalCard.tsx:125 -#: src/components/PlaylistCard.tsx:74 -#: src/components/PlaylistMembershipPanel.tsx:64 +#: src/components/dump/DumpCard.tsx:121 +#: src/components/dump/JournalCard.tsx:125 +#: src/components/playlist/PlaylistCard.tsx:74 +#: src/components/playlist/PlaylistMembershipPanel.tsx:64 #: src/pages/Dump.tsx:474 #: src/pages/PlaylistDetail.tsx:681 msgid "private" @@ -1162,7 +1162,7 @@ msgstr "private" msgid "Private" msgstr "Private" -#: src/components/PlaylistCard.tsx:73 +#: src/components/playlist/PlaylistCard.tsx:73 #: src/pages/PlaylistDetail.tsx:680 msgid "public" msgstr "public" @@ -1176,7 +1176,7 @@ msgstr "Public" msgid "Refresh metadata" msgstr "Refresh metadata" -#: src/components/GlobalPlayer.tsx:304 +#: src/components/player/GlobalPlayer.tsx:304 msgid "Refreshing stream…" msgstr "Refreshing stream…" @@ -1202,7 +1202,7 @@ msgstr "Registration failed" msgid "Related" msgstr "Related" -#: src/components/FileDropZone.tsx:110 +#: src/components/form/FileDropZone.tsx:110 msgid "Remove file" msgstr "Remove file" @@ -1210,11 +1210,11 @@ msgstr "Remove file" msgid "Remove from playlist" msgstr "Remove from playlist" -#: src/components/LikeButton.tsx:31 +#: src/components/social/LikeButton.tsx:31 msgid "Remove like" msgstr "Remove like" -#: src/components/VoteButton.tsx:28 +#: src/components/social/VoteButton.tsx:28 msgid "Remove vote" msgstr "Remove vote" @@ -1222,13 +1222,13 @@ msgstr "Remove vote" msgid "Replace file" msgstr "Replace file" -#: src/components/ChatModal.tsx:326 -#: src/components/ChatModal.tsx:327 -#: src/components/CommentThread.tsx:378 +#: src/components/chat/ChatMessageItem.tsx:67 +#: src/components/chat/ChatMessageItem.tsx:68 +#: src/components/comment/CommentNode.tsx:227 msgid "Reply" msgstr "Reply" -#: src/components/ChatModal.tsx:1255 +#: src/components/chat/ChatModal.tsx:784 msgid "Replying to" msgstr "Replying to" @@ -1249,12 +1249,12 @@ msgstr "Reset password" msgid "Reset to default" msgstr "Reset to default" -#: src/components/DumpCreateModal.tsx:208 -#: src/components/DumpCreateModal.tsx:209 +#: src/components/dump/create/TitleField.tsx:66 +#: src/components/dump/create/TitleField.tsx:67 msgid "Reset to the suggested title" msgstr "Reset to the suggested title" -#: src/components/ChatModal.tsx:519 +#: src/components/chat/PendingItem.tsx:70 #: src/pages/Dump.tsx:283 #: src/pages/DumpEdit.tsx:180 msgid "Retry" @@ -1264,9 +1264,9 @@ msgstr "Retry" msgid "Role" msgstr "Role" -#: src/components/CategoryManager.tsx:204 -#: src/components/ChatModal.tsx:232 -#: src/components/CommentThread.tsx:350 +#: src/components/category/CategoryManager.tsx:204 +#: src/components/chat/ChatEditBox.tsx:66 +#: src/components/comment/CommentNode.tsx:199 #: src/pages/Dump.tsx:401 #: src/pages/DumpEdit.tsx:505 #: src/pages/PlaylistDetail.tsx:982 @@ -1275,8 +1275,8 @@ msgstr "Role" msgid "Save" msgstr "Save" -#: src/components/ChangePasswordModal.tsx:107 -#: src/components/CommentThread.tsx:351 +#: src/components/comment/CommentNode.tsx:200 +#: src/components/profile/ChangePasswordModal.tsx:107 #: src/pages/Dump.tsx:400 #: src/pages/PlaylistDetail.tsx:978 #: src/pages/ResetPassword.tsx:126 @@ -1285,21 +1285,21 @@ msgstr "Save" msgid "Saving…" msgstr "Saving…" -#: src/components/AppHeader.tsx:128 -#: src/components/SearchBar.tsx:73 +#: src/components/layout/AppHeader.tsx:128 +#: src/components/layout/SearchBar.tsx:73 #: src/pages/Search.tsx:51 msgid "Search" msgstr "Search" -#: src/components/SearchBar.tsx:69 +#: src/components/layout/SearchBar.tsx:69 msgid "Search dumps, users, playlists…" msgstr "Search dumps, users, playlists…" -#: src/components/TextEditor.tsx:641 +#: src/components/editor/EmojiPickerPopup.tsx:81 msgid "Search emoji" msgstr "Search emoji" -#: src/components/TextEditor.tsx:640 +#: src/components/editor/EmojiPickerPopup.tsx:80 msgid "Search emoji…" msgstr "Search emoji…" @@ -1311,11 +1311,11 @@ msgstr "Search failed" msgid "Searching…" msgstr "Searching…" -#: src/components/MediaPlayer.tsx:180 +#: src/components/player/SeekBar.tsx:34 msgid "Seek" msgstr "Seek" -#: src/components/ChatModal.tsx:1295 +#: src/components/chat/ChatModal.tsx:824 msgid "Send" msgstr "Send" @@ -1323,12 +1323,12 @@ msgstr "Send" msgid "Send reset link" msgstr "Send reset link" -#: src/components/ChatModal.tsx:537 +#: src/components/chat/PendingItem.tsx:88 #: src/pages/UserLogin.tsx:146 msgid "Sending…" msgstr "Sending…" -#: src/components/AppHeader.tsx:165 +#: src/components/layout/AppHeader.tsx:165 msgid "Server unreachable" msgstr "Server unreachable" @@ -1341,7 +1341,7 @@ msgstr "Set new password" msgid "Settings" msgstr "Settings" -#: src/components/ChatModal.tsx:1079 +#: src/components/chat/ChatModal.tsx:608 msgid "Several people are typing…" msgstr "Several people are typing…" @@ -1349,16 +1349,16 @@ msgstr "Several people are typing…" msgid "Single use, valid for 7 days." msgstr "Single use, valid for 7 days." -#: src/components/CategoryManager.tsx:193 +#: src/components/category/CategoryManager.tsx:193 msgid "slug" msgstr "slug" -#: src/components/TextEditor.tsx:306 +#: src/components/editor/TextEditor.tsx:276 msgid "Some images couldn't be uploaded." msgstr "Some images couldn't be uploaded." #: src/components/form/FormError.tsx:16 -#: src/components/PageError.tsx:13 +#: src/components/layout/PageError.tsx:13 #: src/contexts/WSProvider.tsx:668 #: src/pages/PlaylistDetail.tsx:715 msgid "Something went wrong" @@ -1368,7 +1368,7 @@ msgstr "Something went wrong" msgid "Sounds" msgstr "Sounds" -#: src/components/DumpCreateModal.tsx:718 +#: src/components/dump/create/DumpCreateModal.tsx:537 msgid "Start fresh" msgstr "Start fresh" @@ -1376,7 +1376,7 @@ msgstr "Start fresh" msgid "Style" msgstr "Style" -#: src/components/SearchBar.tsx:79 +#: src/components/layout/SearchBar.tsx:79 msgid "Submit search" msgstr "Submit search" @@ -1388,11 +1388,11 @@ msgstr "the chat" msgid "The dump couldn't be removed. Try again." msgstr "The dump couldn't be removed. Try again." -#: src/components/TextEditor.tsx:307 +#: src/components/editor/TextEditor.tsx:277 msgid "The image couldn't be uploaded." msgstr "The image couldn't be uploaded." -#: src/components/CommentThread.tsx:416 +#: src/components/comment/CommentNode.tsx:265 msgid "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again." @@ -1416,31 +1416,31 @@ msgstr "This reset link is missing or malformed." msgid "Thumbnail" msgstr "Thumbnail" -#: src/components/DumpCreateModal.tsx:174 -#: src/components/PlaylistCreateForm.tsx:74 +#: src/components/dump/create/TitleField.tsx:32 +#: src/components/playlist/PlaylistCreateForm.tsx:74 #: src/pages/DumpEdit.tsx:454 msgid "Title" msgstr "Title" -#: src/components/DumpCreateModal.tsx:586 +#: src/components/dump/create/DumpCreateModal.tsx:405 msgid "Title is required." msgstr "Title is required." -#: src/components/ChatModal.tsx:147 +#: src/components/chat/time.ts:16 #: src/pages/Notifications.tsx:433 msgid "Today" msgstr "Today" #. placeholder {0}: i + 1 -#: src/components/GlobalPlayer.tsx:318 +#: src/components/player/GlobalPlayer.tsx:318 msgid "Track {0}" msgstr "Track {0}" -#: src/components/ChatModal.tsx:1096 +#: src/components/chat/ChatModal.tsx:625 msgid "Try again" msgstr "Try again" -#: src/components/ChatModal.tsx:1280 +#: src/components/chat/ChatModal.tsx:809 msgid "Type a message…" msgstr "Type a message…" @@ -1448,15 +1448,15 @@ msgstr "Type a message…" msgid "Undo" msgstr "Undo" -#: src/components/FollowButton.tsx:34 +#: src/components/social/FollowButton.tsx:34 msgid "Unfollow {targetUsername}" msgstr "Unfollow {targetUsername}" -#: src/components/FollowButton.tsx:62 +#: src/components/social/FollowButton.tsx:62 msgid "Unfollow playlist" msgstr "Unfollow playlist" -#: src/components/MediaPlayer.tsx:500 +#: src/components/player/MediaPlayer.tsx:321 msgid "Unmute" msgstr "Unmute" @@ -1465,19 +1465,19 @@ msgid "Upload failed" msgstr "Upload failed" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/DumpCreateModal.tsx:654 +#: src/components/dump/create/DumpCreateModal.tsx:473 msgid "Uploading {0}%" msgstr "Uploading {0}%" -#: src/components/TextEditor.tsx:555 +#: src/components/editor/TextEditor.tsx:525 msgid "Uploading image…" msgstr "Uploading image…" -#: src/components/DumpCreateModal.tsx:969 +#: src/components/dump/create/DumpCreateModal.tsx:788 msgid "Uploading…" msgstr "Uploading…" -#: src/components/VoteButton.tsx:28 +#: src/components/social/VoteButton.tsx:28 msgid "Upvote" msgstr "Upvote" @@ -1495,11 +1495,11 @@ msgstr "Upvoted ({0}{1})" msgid "URL" msgstr "URL" -#: src/components/DumpCreateModal.tsx:570 +#: src/components/dump/create/DumpCreateModal.tsx:389 msgid "URL is required." msgstr "URL is required." -#: src/components/CategoryManager.tsx:194 +#: src/components/category/CategoryManager.tsx:194 msgid "URL slug" msgstr "URL slug" @@ -1511,7 +1511,7 @@ msgstr "User" msgid "User management" msgstr "User management" -#: src/components/UserMenu.tsx:37 +#: src/components/layout/UserMenu.tsx:37 msgid "User menu" msgstr "User menu" @@ -1531,15 +1531,15 @@ msgstr "Users" msgid "View all →" msgstr "View all →" -#: src/components/DumpCreateModal.tsx:732 +#: src/components/dump/create/DumpCreateModal.tsx:551 msgid "View dump →" msgstr "View dump →" -#: src/components/MediaPlayer.tsx:517 +#: src/components/player/MediaPlayer.tsx:338 msgid "Volume" msgstr "Volume" -#: src/components/DumpCreateModal.tsx:920 +#: src/components/dump/create/DumpCreateModal.tsx:739 #: src/pages/DumpEdit.tsx:475 msgid "What makes it worth it?" msgstr "What makes it worth it?" @@ -1549,29 +1549,29 @@ msgstr "What makes it worth it?" msgid "Who am I?" msgstr "Who am I?" -#: src/components/DumpCreateModal.tsx:935 +#: src/components/dump/create/DumpCreateModal.tsx:754 msgid "Who can see it" msgstr "Who can see it" -#: src/components/DumpCreateModal.tsx:67 +#: src/components/dump/create/StepIndicator.tsx:13 msgid "Why & where" msgstr "Why & where" -#: src/components/DumpCreateModal.tsx:919 +#: src/components/dump/create/DumpCreateModal.tsx:738 #: src/pages/DumpEdit.tsx:474 msgid "Why?" msgstr "Why?" -#: src/components/CommentThread.tsx:427 +#: src/components/comment/CommentNode.tsx:276 msgid "Write a reply…" msgstr "Write a reply…" -#: src/components/ChatModal.tsx:150 +#: src/components/chat/time.ts:19 #: src/pages/Notifications.tsx:435 msgid "Yesterday" msgstr "Yesterday" -#: src/components/DumpCreateModal.tsx:254 +#: src/components/dump/create/DuplicateNotice.tsx:42 msgid "You can post it anyway." msgstr "You can post it anyway." diff --git a/src/locales/fr.po b/src/locales/fr.po index 16b8d979..3a70aad8 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -13,39 +13,39 @@ msgstr "" "Last-Translator: \n" "Language-Team: \n" -#: src/components/CommentThread.tsx:274 +#: src/components/comment/CommentNode.tsx:123 msgid "[deleted]" msgstr "[supprimé]" #. placeholder {0}: dump.commentCount -#: src/components/DumpCard.tsx:112 -#: src/components/JournalCard.tsx:116 +#: src/components/dump/DumpCard.tsx:112 +#: src/components/dump/JournalCard.tsx:116 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" #. placeholder {0}: playlist.dumpCount -#: src/components/PlaylistCard.tsx:87 +#: src/components/playlist/PlaylistCard.tsx:87 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# reco} other {# recos}}" #. placeholder {0}: rest.length -#: src/components/DumpCreateModal.tsx:246 +#: src/components/dump/create/DuplicateNotice.tsx:34 msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus anciennes}}" #. placeholder {0}: typingNames[0] #. placeholder {1}: typingNames[1] -#: src/components/ChatModal.tsx:1078 +#: src/components/chat/ChatModal.tsx:607 msgid "{0} and {1} are typing…" msgstr "{0} et {1} sont en train d'écrire…" #. placeholder {0}: typingNames[0] -#: src/components/ChatModal.tsx:1076 +#: src/components/chat/ChatModal.tsx:605 msgid "{0} is typing…" msgstr "{0} est en train d'écrire…" #. placeholder {0}: rest.length -#: src/components/PresenceRow.tsx:66 +#: src/pages/index/PresenceRow.tsx:66 msgid "{0} more online: {restNames}" msgstr "{0} de plus en ligne : {restNames}" @@ -59,15 +59,15 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" msgid "{label} ({count})" msgstr "{label} ({count})" -#: src/components/ChatModal.tsx:1207 +#: src/components/chat/ChatModal.tsx:736 msgid "{unseenCount} new messages" msgstr "{unseenCount} nouveaux messages" -#: src/components/CommentThread.tsx:484 +#: src/components/comment/CommentThread.tsx:64 msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" -#: src/components/DumpCreateModal.tsx:950 +#: src/components/dump/create/DumpCreateModal.tsx:769 #: src/pages/PlaylistDetail.tsx:607 #: src/pages/UserPublicProfile.tsx:819 msgid "← Back" @@ -89,7 +89,7 @@ msgstr "← Retour au profil" msgid "+ Invite someone" msgstr "+ Inviter quelqu'un" -#: src/components/PlaylistMembershipPanel.tsx:90 +#: src/components/playlist/PlaylistMembershipPanel.tsx:90 msgid "+ New playlist" msgstr "+ Nouvelle collection" @@ -143,7 +143,7 @@ msgstr "<0>{0} a voté pour <1>{1}" msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" -#: src/components/ChatModal.tsx:1206 +#: src/components/chat/ChatModal.tsx:735 msgid "1 new message" msgstr "1 nouveau message" @@ -159,12 +159,12 @@ msgstr "une publication" msgid "Account" msgstr "Compte" -#: src/components/CommentThread.tsx:504 +#: src/components/comment/CommentThread.tsx:84 msgid "Add a comment…" msgstr "Ajouter un commentaire…" -#: src/components/CategoryManager.tsx:96 -#: src/components/CategoryManager.tsx:97 +#: src/components/category/CategoryManager.tsx:96 +#: src/components/category/CategoryManager.tsx:97 msgid "Add category" msgstr "Ajouter une catégorie" @@ -172,8 +172,8 @@ msgstr "Ajouter une catégorie" msgid "Add email…" msgstr "Ajouter un e-mail…" -#: src/components/AddToPlaylistModal.tsx:59 -#: src/components/DumpCreateModal.tsx:671 +#: src/components/dump/create/DumpCreateModal.tsx:490 +#: src/components/playlist/AddToPlaylistModal.tsx:59 msgid "Add to playlist" msgstr "Ajouter à la collection" @@ -181,14 +181,14 @@ msgstr "Ajouter à la collection" msgid "Admin" msgstr "Administrateur" -#: src/components/CategorySwitcher.tsx:80 -#: src/components/CategorySwitcher.tsx:83 +#: src/components/category/CategorySwitcher.tsx:80 +#: src/components/category/CategorySwitcher.tsx:83 msgid "All" msgstr "Tout" #. placeholder {0}: first.username #. placeholder {1}: relativeTime(first.createdAt) -#: src/components/DumpCreateModal.tsx:233 +#: src/components/dump/create/DuplicateNotice.tsx:21 msgid "Already dumped by {0} {1}" msgstr "Déjà recommandé par {0} {1}" @@ -201,7 +201,7 @@ msgid "Appearance" msgstr "Apparence" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/components/ChangePasswordModal.tsx:89 +#: src/components/profile/ChangePasswordModal.tsx:89 #: src/pages/ResetPassword.tsx:111 #: src/pages/UserRegister.tsx:149 msgid "At least {0} characters" @@ -225,13 +225,13 @@ msgstr "Retour à la connexion" msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." -#: src/components/CategoryManager.tsx:213 -#: src/components/CategoryManager.tsx:214 -#: src/components/ChatModal.tsx:235 -#: src/components/CommentThread.tsx:129 -#: src/components/ConfirmModal.tsx:47 -#: src/components/DumpCreateModal.tsx:960 +#: src/components/category/CategoryManager.tsx:213 +#: src/components/category/CategoryManager.tsx:214 +#: src/components/chat/ChatEditBox.tsx:69 +#: src/components/comment/CommentForm.tsx:108 +#: src/components/dump/create/DumpCreateModal.tsx:779 #: src/components/form/FormActions.tsx:32 +#: src/components/ui/ConfirmModal.tsx:47 #: src/pages/Dump.tsx:409 #: src/pages/DumpEdit.tsx:502 #: src/pages/PlaylistDetail.tsx:975 @@ -244,21 +244,21 @@ msgstr "Annuler" msgid "Cancel removal" msgstr "Annuler la suppression" -#: src/components/ChatModal.tsx:1267 -#: src/components/ChatModal.tsx:1268 +#: src/components/chat/ChatModal.tsx:796 +#: src/components/chat/ChatModal.tsx:797 msgid "Cancel reply" msgstr "Annuler la réponse" -#: src/components/AppHeader.tsx:128 +#: src/components/layout/AppHeader.tsx:128 msgid "Cancel search" msgstr "Annuler la recherche" -#: src/components/CategorySelect.tsx:22 +#: src/components/category/CategorySelect.tsx:22 #: src/pages/UserPublicProfile.tsx:1423 msgid "Categories" msgstr "Catégories" -#: src/components/CategorySwitcher.tsx:103 +#: src/components/category/CategorySwitcher.tsx:103 msgid "Category" msgstr "Catégorie" @@ -266,8 +266,8 @@ msgstr "Catégorie" msgid "Change avatar" msgstr "Changer l'avatar" -#: src/components/ChangePasswordModal.tsx:54 -#: src/components/ChangePasswordModal.tsx:108 +#: src/components/profile/ChangePasswordModal.tsx:54 +#: src/components/profile/ChangePasswordModal.tsx:108 msgid "Change password" msgstr "Changer le mot de passe" @@ -275,19 +275,19 @@ msgstr "Changer le mot de passe" msgid "Change password…" msgstr "Changer le mot de passe…" -#: src/components/TextEditor.tsx:648 +#: src/components/editor/EmojiPickerPopup.tsx:85 msgid "Change skin tone" msgstr "Changer la couleur de peau" -#: src/components/ChatButton.tsx:17 -#: src/components/ChatFab.tsx:49 -#: src/components/ChatFab.tsx:50 -#: src/components/ChatModal.tsx:1167 +#: src/components/chat/ChatModal.tsx:696 +#: src/components/layout/ChatButton.tsx:17 +#: src/components/layout/ChatFab.tsx:49 +#: src/components/layout/ChatFab.tsx:50 msgid "Chat" msgstr "Tribune" -#: src/components/ChatButton.tsx:16 -#: src/components/ChatFab.tsx:48 +#: src/components/layout/ChatButton.tsx:16 +#: src/components/layout/ChatFab.tsx:48 msgid "Chat ({unreadChatCount} unread)" msgstr "{unreadChatCount} messages non lus" @@ -295,28 +295,28 @@ msgstr "{unreadChatCount} messages non lus" msgid "Checking invite…" msgstr "Vérification de l'invitation…" -#: src/components/ChangePasswordModal.tsx:63 -#: src/components/Modal.tsx:60 +#: src/components/profile/ChangePasswordModal.tsx:63 +#: src/components/ui/Modal.tsx:60 msgid "Close" msgstr "Fermer" -#: src/components/DumpCreateModal.tsx:702 +#: src/components/dump/create/DumpCreateModal.tsx:521 msgid "Close anyway" msgstr "Fermer quand même" -#: src/components/TextEditor.tsx:657 +#: src/components/editor/EmojiPickerPopup.tsx:90 msgid "Close emoji picker" msgstr "Fermer le sélecteur d'emoji" -#: src/components/GlobalPlayer.tsx:242 +#: src/components/player/GlobalPlayer.tsx:242 msgid "Close player" msgstr "Fermer le lecteur" -#: src/components/DumpCreateModal.tsx:692 +#: src/components/dump/create/DumpCreateModal.tsx:511 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé." -#: src/components/GlobalPlayer.tsx:234 +#: src/components/player/GlobalPlayer.tsx:234 msgid "Collapse player" msgstr "Réduire le lecteur" @@ -324,7 +324,7 @@ msgstr "Réduire le lecteur" msgid "Color scheme" msgstr "Thème de couleur" -#: src/components/ChangePasswordModal.tsx:96 +#: src/components/profile/ChangePasswordModal.tsx:96 #: src/pages/ResetPassword.tsx:118 msgid "Confirm new password" msgstr "Confirmer le nouveau mot de passe" @@ -341,7 +341,7 @@ msgstr "Copié !" msgid "Copy" msgstr "Copier" -#: src/components/ChangePasswordModal.tsx:105 +#: src/components/profile/ChangePasswordModal.tsx:105 msgid "Could not change password" msgstr "Impossible de changer le mot de passe" @@ -350,7 +350,7 @@ msgstr "Impossible de changer le mot de passe" msgid "Could not delete" msgstr "Suppression impossible" -#: src/components/UserListPopover.tsx:141 +#: src/components/social/UserListPopover.tsx:141 msgid "Could not load." msgstr "Impossible de charger." @@ -358,19 +358,19 @@ msgstr "Impossible de charger." msgid "Could not save" msgstr "Sauvegarde impossible" -#: src/components/DumpCreateModal.tsx:814 +#: src/components/dump/create/DumpCreateModal.tsx:633 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard." -#: src/components/ChatModal.tsx:1093 +#: src/components/chat/ChatModal.tsx:622 msgid "Couldn't load the chat." msgstr "Impossible de charger le chat." -#: src/components/PlaylistCreateForm.tsx:90 +#: src/components/playlist/PlaylistCreateForm.tsx:90 msgid "Create" msgstr "Créer" -#: src/components/PlaylistCreateForm.tsx:90 +#: src/components/playlist/PlaylistCreateForm.tsx:90 msgid "Create & Add" msgstr "Créer et ajouter" @@ -380,11 +380,11 @@ msgstr "Créer et ajouter" msgid "Created ({0}{1})" msgstr "Créées ({0}{1})" -#: src/components/PlaylistCreateForm.tsx:89 +#: src/components/playlist/PlaylistCreateForm.tsx:89 msgid "Creating…" msgstr "Création…" -#: src/components/ChangePasswordModal.tsx:74 +#: src/components/profile/ChangePasswordModal.tsx:74 msgid "Current password" msgstr "Mot de passe actuel" @@ -396,24 +396,24 @@ msgstr "Sombre" msgid "Default tab" msgstr "Onglet par défaut" -#: src/components/CategoryManager.tsx:224 -#: src/components/CategoryManager.tsx:225 -#: src/components/ChatModal.tsx:347 -#: src/components/ChatModal.tsx:348 -#: src/components/ChatModal.tsx:446 -#: src/components/CommentThread.tsx:399 -#: src/components/CommentThread.tsx:405 -#: src/components/ConfirmModal.tsx:17 +#: src/components/category/CategoryManager.tsx:224 +#: src/components/category/CategoryManager.tsx:225 +#: src/components/chat/ChatMessageItem.tsx:88 +#: src/components/chat/ChatMessageItem.tsx:89 +#: src/components/chat/ChatMessageItem.tsx:187 +#: src/components/comment/CommentNode.tsx:248 +#: src/components/comment/CommentNode.tsx:254 +#: src/components/ui/ConfirmModal.tsx:17 #: src/pages/PlaylistDetail.tsx:971 msgid "Delete" msgstr "Supprimer" -#: src/components/CategoryManager.tsx:241 +#: src/components/category/CategoryManager.tsx:241 msgid "Delete category" msgstr "Supprimer la catégorie" #. placeholder {0}: category.name -#: src/components/CategoryManager.tsx:240 +#: src/components/category/CategoryManager.tsx:240 msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible." @@ -427,13 +427,13 @@ msgstr "Supprimer la reco" msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" -#: src/components/PlaylistCard.tsx:110 +#: src/components/playlist/PlaylistCard.tsx:110 #: src/pages/PlaylistDetail.tsx:799 #: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" msgstr "Supprimer la collection" -#: src/components/CommentThread.tsx:404 +#: src/components/comment/CommentNode.tsx:253 msgid "Delete this comment?" msgstr "Supprimer ce commentaire ?" @@ -441,7 +441,7 @@ msgstr "Supprimer ce commentaire ?" msgid "Delete this dump? This cannot be undone." msgstr "Supprimer cette reco ? Cette action est irréversible." -#: src/components/ChatModal.tsx:445 +#: src/components/chat/ChatMessageItem.tsx:186 msgid "Delete this message?" msgstr "Supprimer ce message ?" @@ -450,42 +450,42 @@ msgstr "Supprimer ce message ?" msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." -#: src/components/ChatModal.tsx:269 +#: src/components/chat/ReplyRef.tsx:31 msgid "deleted message" msgstr "message supprimé" -#: src/components/PlaylistCreateForm.tsx:82 +#: src/components/playlist/PlaylistCreateForm.tsx:82 #: src/pages/PlaylistDetail.tsx:932 msgid "Description (optional)" msgstr "Description (facultatif)" -#: src/components/ChatModal.tsx:533 +#: src/components/chat/PendingItem.tsx:84 msgid "Discard" msgstr "Abandonner" -#: src/components/ChatModal.tsx:1171 +#: src/components/chat/ChatModal.tsx:700 msgid "Disconnected — reconnecting…" msgstr "Déconnecté — reconnexion…" -#: src/components/ChatModal.tsx:1243 -#: src/components/ChatModal.tsx:1244 +#: src/components/chat/ChatModal.tsx:772 +#: src/components/chat/ChatModal.tsx:773 msgid "Dismiss" msgstr "Fermer" -#: src/components/DumpCreateModal.tsx:753 +#: src/components/dump/create/DumpCreateModal.tsx:572 msgid "Done" msgstr "Terminé" #. placeholder {0}: dump.fileName -#: src/components/FilePreview.tsx:329 +#: src/components/dump/FilePreview.tsx:330 msgid "Download {0}" msgstr "Télécharger {0}" -#: src/components/FileDropZone.tsx:29 +#: src/components/form/FileDropZone.tsx:29 msgid "Drop a file here" msgstr "Déposez un fichier ici" -#: src/components/DumpCreateModal.tsx:861 +#: src/components/dump/create/DumpCreateModal.tsx:680 msgid "Drop a file here, or paste one" msgstr "Déposez un fichier ici, ou collez-en un" @@ -493,15 +493,15 @@ msgstr "Déposez un fichier ici, ou collez-en un" msgid "Drop a replacement here" msgstr "Déposez un fichier de remplacement ici" -#: src/components/AppHeader.tsx:168 +#: src/components/layout/AppHeader.tsx:168 msgid "Dump" msgstr "Reco" -#: src/components/DumpCreateModal.tsx:973 +#: src/components/dump/create/DumpCreateModal.tsx:792 msgid "Dump it" msgstr "Recommander" -#: src/components/DumpCreateModal.tsx:730 +#: src/components/dump/create/DumpCreateModal.tsx:549 msgid "Dumped!" msgstr "Recommandé !" @@ -521,17 +521,17 @@ msgstr "Recos ({0}{1})" msgid "Earlier" msgstr "Plus tôt" -#: src/components/ChatModal.tsx:336 -#: src/components/ChatModal.tsx:337 -#: src/components/ChatModal.tsx:526 -#: src/components/CommentThread.tsx:390 +#: src/components/chat/ChatMessageItem.tsx:77 +#: src/components/chat/ChatMessageItem.tsx:78 +#: src/components/chat/PendingItem.tsx:77 +#: src/components/comment/CommentNode.tsx:239 #: src/pages/Dump.tsx:529 #: src/pages/PlaylistDetail.tsx:662 msgid "Edit" msgstr "Modifier" #. placeholder {0}: category.name -#: src/components/CategoryManager.tsx:76 +#: src/components/category/CategoryManager.tsx:76 msgid "Edit {0}" msgstr "Modifier {0}" @@ -543,20 +543,20 @@ msgstr "Modifier la description" msgid "Edit email" msgstr "Modifier l'e-mail" -#: src/components/DumpCreateModal.tsx:197 +#: src/components/dump/create/TitleField.tsx:55 #: src/pages/Dump.tsx:425 msgid "Edit title" msgstr "Modifier le titre" -#: src/components/ChatModal.tsx:360 +#: src/components/chat/ChatMessageItem.tsx:101 msgid "edited" msgstr "modifié" #. placeholder {0}: relativeTime(comment.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt) -#: src/components/ChatModal.tsx:361 -#: src/components/CommentThread.tsx:339 +#: src/components/chat/ChatMessageItem.tsx:102 +#: src/components/comment/CommentNode.tsx:188 #: src/pages/Dump.tsx:468 #: src/pages/PlaylistDetail.tsx:701 msgid "edited {0}" @@ -565,8 +565,8 @@ msgstr "modifié {0}" #. placeholder {0}: comment.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString() -#: src/components/ChatModal.tsx:357 -#: src/components/CommentThread.tsx:337 +#: src/components/chat/ChatMessageItem.tsx:98 +#: src/components/comment/CommentNode.tsx:186 #: src/pages/Dump.tsx:466 #: src/pages/PlaylistDetail.tsx:698 msgid "Edited {0}" @@ -589,19 +589,19 @@ msgstr "Adresse e-mail" msgid "Enter a query to search." msgstr "Saisissez une recherche." -#: src/components/GlobalPlayer.tsx:234 +#: src/components/player/GlobalPlayer.tsx:234 msgid "Expand player" msgstr "Agrandir le lecteur" -#: src/components/CategoryManager.tsx:234 +#: src/components/category/CategoryManager.tsx:234 msgid "Failed to create category" msgstr "Échec de la création de la catégorie" -#: src/components/PlaylistCreateForm.tsx:87 +#: src/components/playlist/PlaylistCreateForm.tsx:87 msgid "Failed to create playlist" msgstr "Impossible de créer la collection" -#: src/components/CategoryManager.tsx:157 +#: src/components/category/CategoryManager.tsx:157 msgid "Failed to delete" msgstr "Suppression échouée" @@ -622,15 +622,15 @@ msgstr "Impossible de générer une invitation" msgid "Failed to load" msgstr "Chargement échoué" -#: src/components/DumpCreateModal.tsx:762 +#: src/components/dump/create/DumpCreateModal.tsx:581 msgid "Failed to post" msgstr "Publication échouée" -#: src/components/CommentThread.tsx:503 +#: src/components/comment/CommentThread.tsx:83 msgid "Failed to post comment" msgstr "Impossible de publier le commentaire" -#: src/components/CommentThread.tsx:425 +#: src/components/comment/CommentNode.tsx:274 msgid "Failed to post reply" msgstr "Impossible de publier la réponse" @@ -640,11 +640,11 @@ msgstr "Impossible de publier la réponse" msgid "Failed to save" msgstr "Enregistrement échoué" -#: src/components/CategoryManager.tsx:235 +#: src/components/category/CategoryManager.tsx:235 msgid "Failed to save category" msgstr "Échec de l’enregistrement de la catégorie" -#: src/components/CommentThread.tsx:352 +#: src/components/comment/CommentNode.tsx:201 msgid "Failed to save edit" msgstr "Impossible d'enregistrer la modification" @@ -660,24 +660,24 @@ msgstr "Erreur lors de la mise à jour du rôle" msgid "Feeds" msgstr "Flux" -#: src/components/DumpCreateModal.tsx:801 +#: src/components/dump/create/DumpCreateModal.tsx:620 msgid "Fetching preview…" msgstr "Récupération de l'aperçu…" -#: src/components/DumpCreateModal.tsx:588 +#: src/components/dump/create/DumpCreateModal.tsx:407 msgid "File too large (max 50 MB)." msgstr "Fichier trop volumineux (max 50 Mo)." -#: src/components/FollowButton.tsx:37 -#: src/components/FollowButton.tsx:64 +#: src/components/social/FollowButton.tsx:37 +#: src/components/social/FollowButton.tsx:64 msgid "Follow" msgstr "Suivre" -#: src/components/FollowButton.tsx:35 +#: src/components/social/FollowButton.tsx:35 msgid "Follow {targetUsername}" msgstr "Suivre {targetUsername}" -#: src/components/FollowButton.tsx:62 +#: src/components/social/FollowButton.tsx:62 msgid "Follow playlist" msgstr "Suivre la collection" @@ -689,7 +689,7 @@ msgstr "Suivez des collections publiques pour voir leurs recos ici." msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." -#: src/components/FeedTabBar.tsx:40 +#: src/pages/index/FeedTabBar.tsx:40 #: src/pages/UserPublicProfile.tsx:994 #: src/pages/UserPublicProfile.tsx:1364 msgid "Followed" @@ -705,8 +705,8 @@ msgstr "Suivies ({0}{1})" msgid "Followed playlists" msgstr "Collections suivies" -#: src/components/FollowButton.tsx:37 -#: src/components/FollowButton.tsx:64 +#: src/components/social/FollowButton.tsx:37 +#: src/components/social/FollowButton.tsx:64 #: src/pages/UserPublicProfile.tsx:1103 msgid "Following" msgstr "Abonné" @@ -723,7 +723,7 @@ msgstr "De personnes" msgid "From playlists" msgstr "De collections" -#: src/components/MediaPlayer.tsx:527 +#: src/components/player/MediaPlayer.tsx:348 msgid "Fullscreen" msgstr "Plein écran" @@ -735,7 +735,7 @@ msgstr "Accueil" msgid "Go to login" msgstr "Aller à la connexion" -#: src/components/FeedTabBar.tsx:36 +#: src/pages/index/FeedTabBar.tsx:36 #: src/pages/UserPublicProfile.tsx:1343 msgid "Hot" msgstr "Tendances" @@ -744,7 +744,7 @@ msgstr "Tendances" msgid "If that address is registered you'll receive a reset link shortly." msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu." -#: src/components/ChatModal.tsx:95 +#: src/components/chat/message.ts:25 msgid "image" msgstr "image" @@ -752,8 +752,8 @@ msgstr "image" msgid "In collections" msgstr "Dans les collections" -#: src/components/TextEditor.tsx:533 -#: src/components/TextEditor.tsx:535 +#: src/components/editor/TextEditor.tsx:503 +#: src/components/editor/TextEditor.tsx:505 msgid "Insert emoji" msgstr "Insérer un emoji" @@ -779,12 +779,12 @@ msgstr "invité par" msgid "Invitees" msgstr "Invités" -#: src/components/FeedTabBar.tsx:38 +#: src/pages/index/FeedTabBar.tsx:38 #: src/pages/UserPublicProfile.tsx:1357 msgid "Journal" msgstr "Journal" -#: src/components/DumpCreateModal.tsx:699 +#: src/components/dump/create/DumpCreateModal.tsx:518 msgid "Keep editing" msgstr "Continuer l'édition" @@ -792,15 +792,15 @@ msgstr "Continuer l'édition" msgid "Light" msgstr "Clair" -#: src/components/LikeButton.tsx:31 +#: src/components/social/LikeButton.tsx:31 msgid "Like" msgstr "Aimer" -#: src/components/DumpCreateModal.tsx:776 +#: src/components/dump/create/DumpCreateModal.tsx:595 msgid "Link" msgstr "Lien" -#: src/components/DumpCreateModal.tsx:66 +#: src/components/dump/create/StepIndicator.tsx:12 msgid "Link or file" msgstr "Lien ou fichier" @@ -808,17 +808,17 @@ msgstr "Lien ou fichier" msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" -#: src/components/AppHeader.tsx:203 +#: src/components/layout/AppHeader.tsx:203 msgid "Live updates unavailable." msgstr "Mises à jour en direct indisponibles." -#: src/components/UserListPopover.tsx:237 +#: src/components/social/UserListPopover.tsx:237 #: src/pages/Notifications.tsx:483 #: src/pages/UserPublicProfile.tsx:1140 msgid "Load more" msgstr "Charger plus" -#: src/components/ChatModal.tsx:1192 +#: src/components/chat/ChatModal.tsx:721 msgid "Load older messages" msgstr "Charger les messages plus anciens" @@ -827,7 +827,7 @@ msgstr "Charger les messages plus anciens" msgid "Loading dump…" msgstr "Chargement de la reco…" -#: src/components/ChatModal.tsx:1085 +#: src/components/chat/ChatModal.tsx:614 msgid "Loading messages…" msgstr "Chargement des messages…" @@ -851,12 +851,12 @@ msgstr "Chargement de la collection…" msgid "Loading profile…" msgstr "Chargement du profil…" -#: src/components/CategoryManager.tsx:52 -#: src/components/ChatModal.tsx:1191 -#: src/components/PlaylistMembershipPanel.tsx:28 -#: src/components/TextEditor.tsx:669 -#: src/components/UserListPopover.tsx:198 -#: src/components/UserListPopover.tsx:236 +#: src/components/category/CategoryManager.tsx:52 +#: src/components/chat/ChatModal.tsx:720 +#: src/components/editor/EmojiPickerPopup.tsx:102 +#: src/components/playlist/PlaylistMembershipPanel.tsx:28 +#: src/components/social/UserListPopover.tsx:198 +#: src/components/social/UserListPopover.tsx:236 #: src/pages/index/FollowedFeed.tsx:88 #: src/pages/index/HotFeed.tsx:32 #: src/pages/index/JournalFeed.tsx:29 @@ -873,18 +873,18 @@ msgstr "Chargement du profil…" msgid "Loading…" msgstr "Chargement…" -#: src/components/AppHeader.tsx:176 +#: src/components/layout/AppHeader.tsx:176 #: src/pages/UserLogin.tsx:35 #: src/pages/UserLogin.tsx:61 #: src/pages/UserLogin.tsx:80 msgid "Log in" msgstr "Se connecter" -#: src/components/LikeButton.tsx:32 +#: src/components/social/LikeButton.tsx:32 msgid "Log in to like" msgstr "Se connecter pour aimer" -#: src/components/VoteButton.tsx:29 +#: src/components/social/VoteButton.tsx:29 msgid "Log in to vote" msgstr "Se connecter pour voter" @@ -905,7 +905,7 @@ msgstr "Connexion échouée" msgid "Manage" msgstr "Administration" -#: src/components/FileDropZone.tsx:140 +#: src/components/form/FileDropZone.tsx:140 msgid "Max 50 MB" msgstr "Max 50 Mo" @@ -913,12 +913,12 @@ msgstr "Max 50 Mo" msgid "Moderator" msgstr "Modérateur" -#: src/components/MediaPlayer.tsx:500 +#: src/components/player/MediaPlayer.tsx:321 msgid "Mute" msgstr "Couper le son" -#: src/components/CategoryManager.tsx:180 -#: src/components/CategoryManager.tsx:181 +#: src/components/category/CategoryManager.tsx:180 +#: src/components/category/CategoryManager.tsx:181 msgid "Name" msgstr "Nom" @@ -926,35 +926,35 @@ msgstr "Nom" msgid "new" msgstr "nouveau" -#: src/components/ChatModal.tsx:1120 -#: src/components/FeedTabBar.tsx:37 +#: src/components/chat/ChatModal.tsx:649 +#: src/pages/index/FeedTabBar.tsx:37 #: src/pages/UserPublicProfile.tsx:1350 msgid "New" msgstr "Nouveau" -#: src/components/DumpCreateModal.tsx:671 -#: src/components/DumpFab.tsx:65 -#: src/components/DumpFab.tsx:66 +#: src/components/dump/create/DumpCreateModal.tsx:490 +#: src/components/layout/DumpFab.tsx:65 +#: src/components/layout/DumpFab.tsx:66 #: src/pages/UserDumps.tsx:88 #: src/pages/UserPublicProfile.tsx:1469 msgid "New dump" msgstr "Nouvelle reco" -#: src/components/ChangePasswordModal.tsx:82 +#: src/components/profile/ChangePasswordModal.tsx:82 #: src/pages/ResetPassword.tsx:103 msgid "New password" msgstr "Nouveau mot de passe" -#: src/components/NewPlaylistForm.tsx:33 -#: src/components/NewPlaylistForm.tsx:40 +#: src/components/playlist/NewPlaylistButton.tsx:33 +#: src/components/playlist/NewPlaylistButton.tsx:40 msgid "New playlist" msgstr "Nouvelle collection" -#: src/components/DumpCreateModal.tsx:972 +#: src/components/dump/create/DumpCreateModal.tsx:791 msgid "Next →" msgstr "Suivant →" -#: src/components/GlobalPlayer.tsx:224 +#: src/components/player/GlobalPlayer.tsx:224 msgid "Next track" msgstr "Piste suivante" @@ -972,7 +972,7 @@ msgstr "Aucune reco ne correspond à « {q} »." msgid "No dumps yet. Be the first!" msgstr "Pas encore de recos. Soyez le premier !" -#: src/components/TextEditor.tsx:672 +#: src/components/editor/EmojiPickerPopup.tsx:105 msgid "No emoji found." msgstr "Aucun emoji trouvé." @@ -985,11 +985,11 @@ msgstr "Pas encore de collections suivies." msgid "No invitees yet." msgstr "Aucun invité pour le moment." -#: src/components/ChatModal.tsx:1104 +#: src/components/chat/ChatModal.tsx:633 msgid "No messages yet. Say hello!" msgstr "Aucun message pour l'instant. Dites bonjour !" -#: src/components/UserListPopover.tsx:206 +#: src/components/social/UserListPopover.tsx:206 msgid "No one yet." msgstr "Personne pour le moment." @@ -997,7 +997,7 @@ msgstr "Personne pour le moment." msgid "No playlists match \"{q}\"." msgstr "Aucune collection ne correspond à « {q} »." -#: src/components/PlaylistMembershipPanel.tsx:34 +#: src/components/playlist/PlaylistMembershipPanel.tsx:34 #: src/pages/UserPlaylists.tsx:415 #: src/pages/UserPublicProfile.tsx:1074 msgid "No playlists yet." @@ -1023,14 +1023,14 @@ msgstr "Aucun abonnement pour le moment." msgid "Nothing here yet." msgstr "Rien ici pour l'instant." -#: src/components/NotificationBell.tsx:42 +#: src/components/layout/NotificationBell.tsx:42 #: src/pages/Notifications.tsx:269 #: src/pages/Notifications.tsx:395 #: src/pages/UserPublicProfile.tsx:1372 msgid "Notifications" msgstr "Notifications" -#: src/components/NotificationBell.tsx:41 +#: src/components/layout/NotificationBell.tsx:41 msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} non lues)" @@ -1042,20 +1042,20 @@ msgstr "Désactivés" msgid "On" msgstr "Activés" -#: src/components/TextEditor.tsx:347 +#: src/components/editor/TextEditor.tsx:317 msgid "Only images can be dropped here." msgstr "Seules les images peuvent être déposées ici." #. placeholder {0}: richContent.siteName -#: src/components/RichContentCard.tsx:101 +#: src/components/dump/RichContentCard.tsx:101 msgid "Open on {0}" msgstr "Ouvrir sur {0}" -#: src/components/RichContentCard.tsx:102 +#: src/components/dump/RichContentCard.tsx:102 msgid "Open source" msgstr "Ouvrir la source" -#: src/components/FileDropZone.tsx:134 +#: src/components/form/FileDropZone.tsx:134 msgid "or <0>browse files" msgstr "ou <0>parcourir les fichiers" @@ -1073,7 +1073,7 @@ msgstr "Mot de passe" msgid "Password (min. {0} characters)" msgstr "Mot de passe (min. {0} caractères)" -#: src/components/ChangePasswordModal.tsx:59 +#: src/components/profile/ChangePasswordModal.tsx:59 msgid "Password changed successfully." msgstr "Mot de passe modifié avec succès." @@ -1081,33 +1081,33 @@ msgstr "Mot de passe modifié avec succès." msgid "Password updated" msgstr "Mot de passe mis à jour" -#: src/components/ChangePasswordModal.tsx:102 +#: src/components/profile/ChangePasswordModal.tsx:102 #: src/pages/ResetPassword.tsx:123 msgid "Passwords do not match" msgstr "Les mots de passe ne correspondent pas" -#: src/components/DumpCreateModal.tsx:794 +#: src/components/dump/create/DumpCreateModal.tsx:613 msgid "Paste a link…" msgstr "Collez un lien…" -#: src/components/FilePreview.tsx:114 -#: src/components/FilePreview.tsx:281 -#: src/components/MediaPlayer.tsx:484 -#: src/components/RichContentCard.tsx:83 +#: src/components/dump/FilePreview.tsx:115 +#: src/components/dump/FilePreview.tsx:282 +#: src/components/dump/RichContentCard.tsx:83 +#: src/components/player/MediaPlayer.tsx:305 msgid "Pause" msgstr "Pause" -#: src/components/DumpCreateModal.tsx:711 +#: src/components/dump/create/DumpCreateModal.tsx:530 msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." -#: src/components/FilePreview.tsx:114 -#: src/components/FilePreview.tsx:225 -#: src/components/FilePreview.tsx:246 -#: src/components/FilePreview.tsx:281 -#: src/components/MediaPlayer.tsx:484 -#: src/components/RichContentCard.tsx:83 -#: src/components/RichContentCard.tsx:129 +#: src/components/dump/FilePreview.tsx:115 +#: src/components/dump/FilePreview.tsx:226 +#: src/components/dump/FilePreview.tsx:247 +#: src/components/dump/FilePreview.tsx:282 +#: src/components/dump/RichContentCard.tsx:83 +#: src/components/dump/RichContentCard.tsx:129 +#: src/components/player/MediaPlayer.tsx:305 msgid "Play" msgstr "Lecture" @@ -1115,9 +1115,9 @@ msgstr "Lecture" msgid "Playlist title" msgstr "Titre de la collection" -#: src/components/AppHeader.tsx:149 -#: src/components/DumpCreateModal.tsx:68 -#: src/components/UserMenu.tsx:62 +#: src/components/dump/create/StepIndicator.tsx:14 +#: src/components/layout/AppHeader.tsx:149 +#: src/components/layout/UserMenu.tsx:62 #: src/pages/Search.tsx:191 #: src/pages/UserPlaylists.tsx:382 #: src/pages/UserPublicProfile.tsx:993 @@ -1130,28 +1130,28 @@ msgstr "Collections" msgid "Playlists ({0}{1})" msgstr "Collections ({0}{1})" -#: src/components/CommentThread.tsx:501 +#: src/components/comment/CommentThread.tsx:81 msgid "Post comment" msgstr "Publier le commentaire" -#: src/components/CommentThread.tsx:423 +#: src/components/comment/CommentNode.tsx:272 msgid "Post reply" msgstr "Publier la réponse" -#: src/components/CommentThread.tsx:424 -#: src/components/CommentThread.tsx:502 -#: src/components/DumpCreateModal.tsx:966 +#: src/components/comment/CommentNode.tsx:273 +#: src/components/comment/CommentThread.tsx:82 +#: src/components/dump/create/DumpCreateModal.tsx:785 msgid "Posting…" msgstr "Publication…" -#: src/components/GlobalPlayer.tsx:208 +#: src/components/player/GlobalPlayer.tsx:208 msgid "Previous track" msgstr "Piste précédente" -#: src/components/DumpCard.tsx:121 -#: src/components/JournalCard.tsx:125 -#: src/components/PlaylistCard.tsx:74 -#: src/components/PlaylistMembershipPanel.tsx:64 +#: src/components/dump/DumpCard.tsx:121 +#: src/components/dump/JournalCard.tsx:125 +#: src/components/playlist/PlaylistCard.tsx:74 +#: src/components/playlist/PlaylistMembershipPanel.tsx:64 #: src/pages/Dump.tsx:474 #: src/pages/PlaylistDetail.tsx:681 msgid "private" @@ -1162,7 +1162,7 @@ msgstr "privé" msgid "Private" msgstr "Privé" -#: src/components/PlaylistCard.tsx:73 +#: src/components/playlist/PlaylistCard.tsx:73 #: src/pages/PlaylistDetail.tsx:680 msgid "public" msgstr "public" @@ -1176,7 +1176,7 @@ msgstr "Public" msgid "Refresh metadata" msgstr "Actualiser les métadonnées" -#: src/components/GlobalPlayer.tsx:304 +#: src/components/player/GlobalPlayer.tsx:304 msgid "Refreshing stream…" msgstr "Actualisation du flux…" @@ -1202,7 +1202,7 @@ msgstr "Inscription échouée" msgid "Related" msgstr "Connexe" -#: src/components/FileDropZone.tsx:110 +#: src/components/form/FileDropZone.tsx:110 msgid "Remove file" msgstr "Supprimer le fichier" @@ -1210,11 +1210,11 @@ msgstr "Supprimer le fichier" msgid "Remove from playlist" msgstr "Retirer de la collection" -#: src/components/LikeButton.tsx:31 +#: src/components/social/LikeButton.tsx:31 msgid "Remove like" msgstr "Retirer le j'aime" -#: src/components/VoteButton.tsx:28 +#: src/components/social/VoteButton.tsx:28 msgid "Remove vote" msgstr "Retirer le vote" @@ -1222,13 +1222,13 @@ msgstr "Retirer le vote" msgid "Replace file" msgstr "Remplacer le fichier" -#: src/components/ChatModal.tsx:326 -#: src/components/ChatModal.tsx:327 -#: src/components/CommentThread.tsx:378 +#: src/components/chat/ChatMessageItem.tsx:67 +#: src/components/chat/ChatMessageItem.tsx:68 +#: src/components/comment/CommentNode.tsx:227 msgid "Reply" msgstr "Répondre" -#: src/components/ChatModal.tsx:1255 +#: src/components/chat/ChatModal.tsx:784 msgid "Replying to" msgstr "En réponse à" @@ -1249,12 +1249,12 @@ msgstr "Réinitialiser le mot de passe" msgid "Reset to default" msgstr "Réinitialiser par défaut" -#: src/components/DumpCreateModal.tsx:208 -#: src/components/DumpCreateModal.tsx:209 +#: src/components/dump/create/TitleField.tsx:66 +#: src/components/dump/create/TitleField.tsx:67 msgid "Reset to the suggested title" msgstr "Rétablir le titre suggéré" -#: src/components/ChatModal.tsx:519 +#: src/components/chat/PendingItem.tsx:70 #: src/pages/Dump.tsx:283 #: src/pages/DumpEdit.tsx:180 msgid "Retry" @@ -1264,9 +1264,9 @@ msgstr "Réessayer" msgid "Role" msgstr "Rôle" -#: src/components/CategoryManager.tsx:204 -#: src/components/ChatModal.tsx:232 -#: src/components/CommentThread.tsx:350 +#: src/components/category/CategoryManager.tsx:204 +#: src/components/chat/ChatEditBox.tsx:66 +#: src/components/comment/CommentNode.tsx:199 #: src/pages/Dump.tsx:401 #: src/pages/DumpEdit.tsx:505 #: src/pages/PlaylistDetail.tsx:982 @@ -1275,8 +1275,8 @@ msgstr "Rôle" msgid "Save" msgstr "Enregistrer" -#: src/components/ChangePasswordModal.tsx:107 -#: src/components/CommentThread.tsx:351 +#: src/components/comment/CommentNode.tsx:200 +#: src/components/profile/ChangePasswordModal.tsx:107 #: src/pages/Dump.tsx:400 #: src/pages/PlaylistDetail.tsx:978 #: src/pages/ResetPassword.tsx:126 @@ -1285,21 +1285,21 @@ msgstr "Enregistrer" msgid "Saving…" msgstr "Enregistrement…" -#: src/components/AppHeader.tsx:128 -#: src/components/SearchBar.tsx:73 +#: src/components/layout/AppHeader.tsx:128 +#: src/components/layout/SearchBar.tsx:73 #: src/pages/Search.tsx:51 msgid "Search" msgstr "Rechercher" -#: src/components/SearchBar.tsx:69 +#: src/components/layout/SearchBar.tsx:69 msgid "Search dumps, users, playlists…" msgstr "Rechercher des recos, utilisateurs, collections…" -#: src/components/TextEditor.tsx:641 +#: src/components/editor/EmojiPickerPopup.tsx:81 msgid "Search emoji" msgstr "Rechercher un emoji" -#: src/components/TextEditor.tsx:640 +#: src/components/editor/EmojiPickerPopup.tsx:80 msgid "Search emoji…" msgstr "Rechercher un emoji…" @@ -1311,11 +1311,11 @@ msgstr "Recherche échouée" msgid "Searching…" msgstr "Recherche…" -#: src/components/MediaPlayer.tsx:180 +#: src/components/player/SeekBar.tsx:34 msgid "Seek" msgstr "Position" -#: src/components/ChatModal.tsx:1295 +#: src/components/chat/ChatModal.tsx:824 msgid "Send" msgstr "Envoyer" @@ -1323,12 +1323,12 @@ msgstr "Envoyer" msgid "Send reset link" msgstr "Envoyer le lien de réinitialisation" -#: src/components/ChatModal.tsx:537 +#: src/components/chat/PendingItem.tsx:88 #: src/pages/UserLogin.tsx:146 msgid "Sending…" msgstr "Envoi…" -#: src/components/AppHeader.tsx:165 +#: src/components/layout/AppHeader.tsx:165 msgid "Server unreachable" msgstr "Serveur inaccessible" @@ -1341,7 +1341,7 @@ msgstr "Définir un nouveau mot de passe" msgid "Settings" msgstr "Paramètres" -#: src/components/ChatModal.tsx:1079 +#: src/components/chat/ChatModal.tsx:608 msgid "Several people are typing…" msgstr "Plusieurs personnes écrivent…" @@ -1349,16 +1349,16 @@ msgstr "Plusieurs personnes écrivent…" msgid "Single use, valid for 7 days." msgstr "Usage unique, valable 7 jours." -#: src/components/CategoryManager.tsx:193 +#: src/components/category/CategoryManager.tsx:193 msgid "slug" msgstr "identifiant" -#: src/components/TextEditor.tsx:306 +#: src/components/editor/TextEditor.tsx:276 msgid "Some images couldn't be uploaded." msgstr "Certaines images n'ont pas pu être envoyées." #: src/components/form/FormError.tsx:16 -#: src/components/PageError.tsx:13 +#: src/components/layout/PageError.tsx:13 #: src/contexts/WSProvider.tsx:668 #: src/pages/PlaylistDetail.tsx:715 msgid "Something went wrong" @@ -1368,7 +1368,7 @@ msgstr "Une erreur est survenue" msgid "Sounds" msgstr "Sons" -#: src/components/DumpCreateModal.tsx:718 +#: src/components/dump/create/DumpCreateModal.tsx:537 msgid "Start fresh" msgstr "Repartir de zéro" @@ -1376,7 +1376,7 @@ msgstr "Repartir de zéro" msgid "Style" msgstr "Style" -#: src/components/SearchBar.tsx:79 +#: src/components/layout/SearchBar.tsx:79 msgid "Submit search" msgstr "Lancer la recherche" @@ -1388,11 +1388,11 @@ msgstr "la tribune" msgid "The dump couldn't be removed. Try again." msgstr "Le dump n'a pas pu être retiré. Réessayez." -#: src/components/TextEditor.tsx:307 +#: src/components/editor/TextEditor.tsx:277 msgid "The image couldn't be uploaded." msgstr "L'image n'a pas pu être envoyée." -#: src/components/CommentThread.tsx:416 +#: src/components/comment/CommentNode.tsx:265 msgid "This comment couldn't be deleted. Try again." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." @@ -1416,31 +1416,31 @@ msgstr "Ce lien de réinitialisation est absent ou malformé." msgid "Thumbnail" msgstr "Miniature" -#: src/components/DumpCreateModal.tsx:174 -#: src/components/PlaylistCreateForm.tsx:74 +#: src/components/dump/create/TitleField.tsx:32 +#: src/components/playlist/PlaylistCreateForm.tsx:74 #: src/pages/DumpEdit.tsx:454 msgid "Title" msgstr "Titre" -#: src/components/DumpCreateModal.tsx:586 +#: src/components/dump/create/DumpCreateModal.tsx:405 msgid "Title is required." msgstr "Un titre est requis." -#: src/components/ChatModal.tsx:147 +#: src/components/chat/time.ts:16 #: src/pages/Notifications.tsx:433 msgid "Today" msgstr "Aujourd'hui" #. placeholder {0}: i + 1 -#: src/components/GlobalPlayer.tsx:318 +#: src/components/player/GlobalPlayer.tsx:318 msgid "Track {0}" msgstr "Piste {0}" -#: src/components/ChatModal.tsx:1096 +#: src/components/chat/ChatModal.tsx:625 msgid "Try again" msgstr "Réessayer" -#: src/components/ChatModal.tsx:1280 +#: src/components/chat/ChatModal.tsx:809 msgid "Type a message…" msgstr "Écrivez un message…" @@ -1448,15 +1448,15 @@ msgstr "Écrivez un message…" msgid "Undo" msgstr "Annuler" -#: src/components/FollowButton.tsx:34 +#: src/components/social/FollowButton.tsx:34 msgid "Unfollow {targetUsername}" msgstr "Ne plus suivre {targetUsername}" -#: src/components/FollowButton.tsx:62 +#: src/components/social/FollowButton.tsx:62 msgid "Unfollow playlist" msgstr "Ne plus suivre la collection" -#: src/components/MediaPlayer.tsx:500 +#: src/components/player/MediaPlayer.tsx:321 msgid "Unmute" msgstr "Rétablir le son" @@ -1465,19 +1465,19 @@ msgid "Upload failed" msgstr "Envoi échoué" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/DumpCreateModal.tsx:654 +#: src/components/dump/create/DumpCreateModal.tsx:473 msgid "Uploading {0}%" msgstr "Envoi {0} %" -#: src/components/TextEditor.tsx:555 +#: src/components/editor/TextEditor.tsx:525 msgid "Uploading image…" msgstr "Envoi de l'image…" -#: src/components/DumpCreateModal.tsx:969 +#: src/components/dump/create/DumpCreateModal.tsx:788 msgid "Uploading…" msgstr "Envoi…" -#: src/components/VoteButton.tsx:28 +#: src/components/social/VoteButton.tsx:28 msgid "Upvote" msgstr "Voter" @@ -1495,11 +1495,11 @@ msgstr "Votés ({0}{1})" msgid "URL" msgstr "URL" -#: src/components/DumpCreateModal.tsx:570 +#: src/components/dump/create/DumpCreateModal.tsx:389 msgid "URL is required." msgstr "L'URL est obligatoire." -#: src/components/CategoryManager.tsx:194 +#: src/components/category/CategoryManager.tsx:194 msgid "URL slug" msgstr "Identifiant d’URL" @@ -1511,7 +1511,7 @@ msgstr "Utilisateur" msgid "User management" msgstr "Gestion utilisateur" -#: src/components/UserMenu.tsx:37 +#: src/components/layout/UserMenu.tsx:37 msgid "User menu" msgstr "Menu utilisateur" @@ -1531,15 +1531,15 @@ msgstr "Utilisateurs" msgid "View all →" msgstr "Tout voir →" -#: src/components/DumpCreateModal.tsx:732 +#: src/components/dump/create/DumpCreateModal.tsx:551 msgid "View dump →" msgstr "Voir la reco →" -#: src/components/MediaPlayer.tsx:517 +#: src/components/player/MediaPlayer.tsx:338 msgid "Volume" msgstr "Volume" -#: src/components/DumpCreateModal.tsx:920 +#: src/components/dump/create/DumpCreateModal.tsx:739 #: src/pages/DumpEdit.tsx:475 msgid "What makes it worth it?" msgstr "Pourquoi on en voudrait ?" @@ -1549,29 +1549,29 @@ msgstr "Pourquoi on en voudrait ?" msgid "Who am I?" msgstr "Qui suis-je ?" -#: src/components/DumpCreateModal.tsx:935 +#: src/components/dump/create/DumpCreateModal.tsx:754 msgid "Who can see it" msgstr "Qui peut la voir" -#: src/components/DumpCreateModal.tsx:67 +#: src/components/dump/create/StepIndicator.tsx:13 msgid "Why & where" msgstr "Pourquoi & où" -#: src/components/DumpCreateModal.tsx:919 +#: src/components/dump/create/DumpCreateModal.tsx:738 #: src/pages/DumpEdit.tsx:474 msgid "Why?" msgstr "Pourquoi ?" -#: src/components/CommentThread.tsx:427 +#: src/components/comment/CommentNode.tsx:276 msgid "Write a reply…" msgstr "Écrire une réponse…" -#: src/components/ChatModal.tsx:150 +#: src/components/chat/time.ts:19 #: src/pages/Notifications.tsx:435 msgid "Yesterday" msgstr "Hier" -#: src/components/DumpCreateModal.tsx:254 +#: src/components/dump/create/DuplicateNotice.tsx:42 msgid "You can post it anyway." msgstr "Vous pouvez la publier quand même." diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 89dbf404..44664cf5 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { can } from "../utils/permissions.ts"; -import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx"; +import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx"; import { API_URL, VALIDATION } from "../config/api.ts"; @@ -32,17 +32,17 @@ import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { relativeTime } from "../utils/relativeTime.ts"; import { useHeightTransition } from "../hooks/useHeightTransition.ts"; import { useWS } from "../hooks/useWS.ts"; -import { Avatar } from "../components/Avatar.tsx"; -import RichContentCard from "../components/RichContentCard.tsx"; -import FilePreview from "../components/FilePreview.tsx"; -import { VoteButton } from "../components/VoteButton.tsx"; -import { DumpCard } from "../components/DumpCard.tsx"; -import { PlaylistCard } from "../components/PlaylistCard.tsx"; -import { PageShell } from "../components/PageShell.tsx"; -import { PageError } from "../components/PageError.tsx"; -import { Markdown } from "../components/Markdown.tsx"; -import { CommentThread } from "../components/CommentThread.tsx"; -import { Tooltip } from "../components/Tooltip.tsx"; +import { Avatar } from "../components/ui/Avatar.tsx"; +import { RichContentCard } from "../components/dump/RichContentCard.tsx"; +import { FilePreview } from "../components/dump/FilePreview.tsx"; +import { VoteButton } from "../components/social/VoteButton.tsx"; +import { DumpCard } from "../components/dump/DumpCard.tsx"; +import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageError } from "../components/layout/PageError.tsx"; +import { Markdown } from "../components/editor/Markdown.tsx"; +import { CommentThread } from "../components/comment/CommentThread.tsx"; +import { Tooltip } from "../components/ui/Tooltip.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; type DumpState = diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 5cf01ccb..17d2927e 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -10,15 +10,15 @@ import { useRequiredAuth } from "../hooks/useAuth.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { formatBytes } from "../utils/format.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; -import { PageShell } from "../components/PageShell.tsx"; -import { PageError } from "../components/PageError.tsx"; -import { ErrorCard } from "../components/ErrorCard.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageError } from "../components/layout/PageError.tsx"; +import { ErrorCard } from "../components/ui/ErrorCard.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; -import { ConfirmModal } from "../components/ConfirmModal.tsx"; -import RichContentCard from "../components/RichContentCard.tsx"; -import FilePreview from "../components/FilePreview.tsx"; -import { ImagePicker } from "../components/ImagePicker.tsx"; -import { CategorySelect } from "../components/CategorySelect.tsx"; +import { ConfirmModal } from "../components/ui/ConfirmModal.tsx"; +import { RichContentCard } from "../components/dump/RichContentCard.tsx"; +import { FilePreview } from "../components/dump/FilePreview.tsx"; +import { ImagePicker } from "../components/ui/ImagePicker.tsx"; +import { CategorySelect } from "../components/category/CategorySelect.tsx"; import { expectOk, FileField, diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 4ce5d144..a6461325 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -8,9 +8,9 @@ import { } from "react"; import { useLocation, useParams } from "react-router"; -import { AppHeader } from "../components/AppHeader.tsx"; -import { PresenceRow } from "../components/PresenceRow.tsx"; -import { FeedTabBar } from "../components/FeedTabBar.tsx"; +import { AppHeader } from "../components/layout/AppHeader.tsx"; +import { PresenceRow } from "./index/PresenceRow.tsx"; +import { FeedTabBar } from "./index/FeedTabBar.tsx"; import { DEFAULT_FEED_TAB, FEED_TABS, diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index 91f3a030..4d3e85ec 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,7 +1,7 @@ import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { PageShell } from "../components/PageShell.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; export function NotFound() { diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 141022c2..f5e17b69 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -7,8 +7,8 @@ import { relativeTime } from "../utils/relativeTime.ts"; import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; -import { ErrorCard } from "../components/ErrorCard.tsx"; -import { Tooltip } from "../components/Tooltip.tsx"; +import { ErrorCard } from "../components/ui/ErrorCard.tsx"; +import { Tooltip } from "../components/ui/Tooltip.tsx"; import { useWS } from "../hooks/useWS.ts"; import type { CommentLikedData, @@ -25,7 +25,7 @@ import type { UserMentionedData, } from "../model.ts"; import { deserializeNotification } from "../model.ts"; -import { PageShell } from "../components/PageShell.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; import { expectOk } from "../components/form/index.ts"; diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 5eccad96..2a472c68 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -9,7 +9,7 @@ import { Link, useNavigate, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL, VALIDATION } from "../config/api.ts"; -import { CountedInput } from "../components/CountedInput.tsx"; +import { CountedInput } from "../components/ui/CountedInput.tsx"; import type { Playlist, PlaylistWithDumps, @@ -31,16 +31,16 @@ import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useHeightTransition } from "../hooks/useHeightTransition.ts"; import { useWS } from "../hooks/useWS.ts"; import { relativeTime } from "../utils/relativeTime.ts"; -import { DumpCard } from "../components/DumpCard.tsx"; -import { PageShell } from "../components/PageShell.tsx"; -import { PageError } from "../components/PageError.tsx"; -import { ErrorCard } from "../components/ErrorCard.tsx"; -import { ConfirmModal } from "../components/ConfirmModal.tsx"; -import { ImagePicker } from "../components/ImagePicker.tsx"; -import { Markdown } from "../components/Markdown.tsx"; -import { TextEditor } from "../components/TextEditor.tsx"; -import { FollowPlaylistButton } from "../components/FollowButton.tsx"; -import { Tooltip } from "../components/Tooltip.tsx"; +import { DumpCard } from "../components/dump/DumpCard.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageError } from "../components/layout/PageError.tsx"; +import { ErrorCard } from "../components/ui/ErrorCard.tsx"; +import { ConfirmModal } from "../components/ui/ConfirmModal.tsx"; +import { ImagePicker } from "../components/ui/ImagePicker.tsx"; +import { Markdown } from "../components/editor/Markdown.tsx"; +import { TextEditor } from "../components/editor/TextEditor.tsx"; +import { FollowPlaylistButton } from "../components/social/FollowButton.tsx"; +import { Tooltip } from "../components/ui/Tooltip.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; import { Controller } from "react-hook-form"; import { diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx index 1b3b44f0..ec83e459 100644 --- a/src/pages/ResetPassword.tsx +++ b/src/pages/ResetPassword.tsx @@ -5,7 +5,7 @@ import { Trans } from "@lingui/react/macro"; import { API_URL, VALIDATION } from "../config/api.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; -import { PageShell } from "../components/PageShell.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; import { expectOk, FormError, diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 3fc5b80b..2c3d3a22 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { AppHeader } from "../components/AppHeader.tsx"; -import { TabBar } from "../components/TabBar.tsx"; -import { DumpCard } from "../components/DumpCard.tsx"; -import { PlaylistCard } from "../components/PlaylistCard.tsx"; -import { ErrorCard } from "../components/ErrorCard.tsx"; +import { AppHeader } from "../components/layout/AppHeader.tsx"; +import { TabBar } from "../components/ui/TabBar.tsx"; +import { DumpCard } from "../components/dump/DumpCard.tsx"; +import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx"; +import { ErrorCard } from "../components/ui/ErrorCard.tsx"; import { useAuth } from "../hooks/useAuth.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useWS } from "../hooks/useWS.ts"; diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index 37dd011f..ed5017e9 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -9,11 +9,11 @@ import { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts"; -import { DumpCard } from "../components/DumpCard.tsx"; -import { DumpCreateModal } from "../components/DumpCreateModal.tsx"; -import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx"; -import { PageShell } from "../components/PageShell.tsx"; -import { PageError } from "../components/PageError.tsx"; +import { DumpCard } from "../components/dump/DumpCard.tsx"; +import { DumpCreateModal } from "../components/dump/create/DumpCreateModal.tsx"; +import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageError } from "../components/layout/PageError.tsx"; export function UserDumps() { const { username } = useParams(); diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx index 2e49bc8f..b9de9bd8 100644 --- a/src/pages/UserLogin.tsx +++ b/src/pages/UserLogin.tsx @@ -12,7 +12,7 @@ import { import { useAuth } from "../hooks/useAuth.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useHeightTransition } from "../hooks/useHeightTransition.ts"; -import { PageShell } from "../components/PageShell.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; import { expectOk, FormError, diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index a9b355da..c3c6af11 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -30,13 +30,13 @@ import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; import { useScrollSave } from "../hooks/useScrollSave.ts"; -import { PlaylistCard } from "../components/PlaylistCard.tsx"; -import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx"; -import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx"; -import { ConfirmModal } from "../components/ConfirmModal.tsx"; -import { PageShell } from "../components/PageShell.tsx"; -import { PageError } from "../components/PageError.tsx"; -import { ErrorCard } from "../components/ErrorCard.tsx"; +import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx"; +import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx"; +import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx"; +import { ConfirmModal } from "../components/ui/ConfirmModal.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageError } from "../components/layout/PageError.tsx"; +import { ErrorCard } from "../components/ui/ErrorCard.tsx"; import { expectOk } from "../components/form/index.ts"; interface PlaylistFeed { @@ -381,7 +381,7 @@ export function UserPlaylists() { profileUser={profileUser} title={t`Playlists`} actions={isOwnProfile && ( - setState((s) => { diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index eb2b54ff..111e7b07 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -30,13 +30,13 @@ import { type RawPublicUser, type UpdateUserRequest, } from "../model.ts"; -import { Avatar } from "../components/Avatar.tsx"; -import { DumpCard } from "../components/DumpCard.tsx"; -import { PlaylistCard } from "../components/PlaylistCard.tsx"; -import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx"; -import { PageShell } from "../components/PageShell.tsx"; -import { PageError } from "../components/PageError.tsx"; -import { CategoryManager } from "../components/CategoryManager.tsx"; +import { Avatar } from "../components/ui/Avatar.tsx"; +import { DumpCard } from "../components/dump/DumpCard.tsx"; +import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx"; +import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageError } from "../components/layout/PageError.tsx"; +import { CategoryManager } from "../components/category/CategoryManager.tsx"; import { useAuth } from "../hooks/useAuth.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useTheme } from "../hooks/useTheme.ts"; @@ -52,15 +52,15 @@ import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import type { Playlist, RawPlaylist } from "../model.ts"; import { deserializePlaylist } from "../model.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; -import { DumpCreateModal } from "../components/DumpCreateModal.tsx"; -import { FollowUserButton } from "../components/FollowButton.tsx"; -import { ErrorCard } from "../components/ErrorCard.tsx"; +import { DumpCreateModal } from "../components/dump/create/DumpCreateModal.tsx"; +import { FollowUserButton } from "../components/social/FollowButton.tsx"; +import { ErrorCard } from "../components/ui/ErrorCard.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; import { can } from "../utils/permissions.ts"; -import { TextEditor } from "../components/TextEditor.tsx"; -import { Markdown } from "../components/Markdown.tsx"; -import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx"; -import { TabBar } from "../components/TabBar.tsx"; +import { TextEditor } from "../components/editor/TextEditor.tsx"; +import { Markdown } from "../components/editor/Markdown.tsx"; +import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx"; +import { TabBar } from "../components/ui/TabBar.tsx"; import { useTabParam } from "../hooks/useTabParam.ts"; import { Controller } from "react-hook-form"; import { @@ -1046,7 +1046,7 @@ export function UserPublicProfile() { {isOwnProfile && ( - setState((s) => { if (s.status !== "loaded") return s; diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx index 67e5558f..b433331e 100644 --- a/src/pages/UserRegister.tsx +++ b/src/pages/UserRegister.tsx @@ -11,8 +11,8 @@ import { } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; -import { PageShell } from "../components/PageShell.tsx"; -import { ErrorCard } from "../components/ErrorCard.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { ErrorCard } from "../components/ui/ErrorCard.tsx"; import { expectOk, FormError, diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index 5bd6e891..2979dcc1 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -12,10 +12,10 @@ import { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useFading } from "../hooks/useFading.ts"; import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts"; -import { DumpCard } from "../components/DumpCard.tsx"; -import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx"; -import { PageShell } from "../components/PageShell.tsx"; -import { PageError } from "../components/PageError.tsx"; +import { DumpCard } from "../components/dump/DumpCard.tsx"; +import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx"; +import { PageShell } from "../components/layout/PageShell.tsx"; +import { PageError } from "../components/layout/PageError.tsx"; export function UserUpvoted() { const { username } = useParams(); diff --git a/src/components/FeedTabBar.tsx b/src/pages/index/FeedTabBar.tsx similarity index 82% rename from src/components/FeedTabBar.tsx rename to src/pages/index/FeedTabBar.tsx index 77c736cd..9a0033df 100644 --- a/src/components/FeedTabBar.tsx +++ b/src/pages/index/FeedTabBar.tsx @@ -1,14 +1,14 @@ import { useNavigate, useParams } from "react-router"; import { Trans } from "@lingui/react/macro"; -import { useAuth } from "../hooks/useAuth.ts"; +import { useAuth } from "../../hooks/useAuth.ts"; import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab, -} from "../config/feedTabs.ts"; -import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; -import { TabBar } from "./TabBar.tsx"; -import { CategorySwitcher } from "./CategorySwitcher.tsx"; +} from "../../config/feedTabs.ts"; +import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts"; +import { TabBar } from "../../components/ui/TabBar.tsx"; +import { CategorySwitcher } from "../../components/category/CategorySwitcher.tsx"; /** * Feed sort tabs (hot/new/journal/followed). Navigation stays within the diff --git a/src/pages/index/FollowedFeed.tsx b/src/pages/index/FollowedFeed.tsx index bc15999a..efa6cb78 100644 --- a/src/pages/index/FollowedFeed.tsx +++ b/src/pages/index/FollowedFeed.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import { TabBar } from "../../components/TabBar.tsx"; +import { TabBar } from "../../components/ui/TabBar.tsx"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { DumpCard } from "../../components/DumpCard.tsx"; -import { ErrorCard } from "../../components/ErrorCard.tsx"; +import { DumpCard } from "../../components/dump/DumpCard.tsx"; +import { ErrorCard } from "../../components/ui/ErrorCard.tsx"; import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { useAuth } from "../../hooks/useAuth.ts"; import { useDumpListSync } from "../../hooks/useDumpListSync.ts"; diff --git a/src/pages/index/HotFeed.tsx b/src/pages/index/HotFeed.tsx index 560ed18e..c7ca3e5f 100644 --- a/src/pages/index/HotFeed.tsx +++ b/src/pages/index/HotFeed.tsx @@ -1,8 +1,8 @@ import { useMemo } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { DumpCard } from "../../components/DumpCard.tsx"; -import { ErrorCard } from "../../components/ErrorCard.tsx"; +import { DumpCard } from "../../components/dump/DumpCard.tsx"; +import { ErrorCard } from "../../components/ui/ErrorCard.tsx"; import { hotScore } from "../../utils/hotScore.ts"; import type { MainFeedProps } from "./types.ts"; diff --git a/src/pages/index/JournalFeed.tsx b/src/pages/index/JournalFeed.tsx index 2689c98b..09fcb32d 100644 --- a/src/pages/index/JournalFeed.tsx +++ b/src/pages/index/JournalFeed.tsx @@ -1,8 +1,8 @@ import { Fragment, useMemo } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { ErrorCard } from "../../components/ErrorCard.tsx"; -import { JournalCard } from "../../components/JournalCard.tsx"; +import { ErrorCard } from "../../components/ui/ErrorCard.tsx"; +import { JournalCard } from "../../components/dump/JournalCard.tsx"; import { composeJournal } from "../../utils/journalLayout.ts"; import type { MainFeedProps } from "./types.ts"; diff --git a/src/pages/index/NewFeed.tsx b/src/pages/index/NewFeed.tsx index 5ed29b38..c27618b6 100644 --- a/src/pages/index/NewFeed.tsx +++ b/src/pages/index/NewFeed.tsx @@ -1,8 +1,8 @@ import { useMemo } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { DumpCard } from "../../components/DumpCard.tsx"; -import { ErrorCard } from "../../components/ErrorCard.tsx"; +import { DumpCard } from "../../components/dump/DumpCard.tsx"; +import { ErrorCard } from "../../components/ui/ErrorCard.tsx"; import type { MainFeedProps } from "./types.ts"; export function NewFeed( diff --git a/src/components/PresenceRow.tsx b/src/pages/index/PresenceRow.tsx similarity index 93% rename from src/components/PresenceRow.tsx rename to src/pages/index/PresenceRow.tsx index a563dc98..9026d08c 100644 --- a/src/components/PresenceRow.tsx +++ b/src/pages/index/PresenceRow.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; -import { Avatar } from "./Avatar.tsx"; -import { useWSPresence } from "../hooks/useWS.ts"; -import { useRoomBeside } from "../hooks/useRoomBeside.ts"; +import { Avatar } from "../../components/ui/Avatar.tsx"; +import { useWSPresence } from "../../hooks/useWS.ts"; +import { useRoomBeside } from "../../hooks/useRoomBeside.ts"; // Past this many, the row ends in a "+N" counter rather than growing, so a // busy evening doesn't push the feed tabs out of the header. diff --git a/src/test/components/buttons.test.tsx b/src/test/components/buttons.test.tsx deleted file mode 100644 index 8861257a..00000000 --- a/src/test/components/buttons.test.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { useRef } from "react"; -import { Avatar } from "../../components/Avatar.tsx"; -import { VoteButton } from "../../components/VoteButton.tsx"; -import { LikeButton } from "../../components/LikeButton.tsx"; -import { - FollowPlaylistButton, - FollowUserButton, -} from "../../components/FollowButton.tsx"; -import { UserListPopover } from "../../components/UserListPopover.tsx"; -import { NotificationBell } from "../../components/NotificationBell.tsx"; -import { ChatButton } from "../../components/ChatButton.tsx"; -import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts"; -import { ChatContext } from "../../contexts/ChatContext.ts"; -import { calls, fail, route } from "../fetch.ts"; -import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts"; -import { currentPath, renderWithContexts } from "../render.tsx"; -import { defaultWSValue, WSContext } from "../../contexts/WSContext.ts"; - -describe("Avatar", () => { - test("an image when the user has one, versioned", () => { - renderWithContexts( - , - ); - expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute( - "src", - "http://localhost:8000/api/avatars/u1?v=7", - ); - }); - - test("initials otherwise, and after the image fails", () => { - renderWithContexts(); - fireEvent.error(screen.getByRole("img")); - expect(screen.queryByRole("img")).not.toBeInTheDocument(); - expect(screen.getByTitle("bob")).toHaveTextContent("B"); - }); - - test("a live override wins over stale props, and retries a failed image", () => { - const { rerender } = renderWithContexts( - - - , - ); - expect(screen.queryByRole("img")).not.toBeInTheDocument(); - rerender( - - - , - ); - // rerender replaces the tree under the same router wrapper - expect(screen.getByRole("img")).toHaveAttribute( - "src", - expect.stringContaining("?v=2"), - ); - }); -}); - -describe("VoteButton / LikeButton", () => { - test("upvote and remove, passing the count on screen", async () => { - const onCast = vi.fn(); - const onRemove = vi.fn(); - const user = userEvent.setup(); - const { rerender } = renderWithContexts( - , - ); - await user.click(screen.getByRole("button", { name: "Upvote" })); - expect(onCast).toHaveBeenCalledWith("d", 0); - rerender( - , - ); - await user.click(screen.getByRole("button", { name: "Remove vote" })); - expect(onRemove).toHaveBeenCalledWith("d", 3); - }); - - test("disabled for guests, with a hint", () => { - renderWithContexts( - {}} - onRemove={() => {}} - />, - ); - const btn = screen.getByRole("button", { name: "Upvote" }); - expect(btn).toBeDisabled(); - expect(btn).toHaveAttribute("title", "Log in to vote"); - }); - - test("the count opens who voted, without voting", async () => { - const onCast = vi.fn(); - route( - "GET /api/dumps/:id/voters", - () => page([makeUser({ username: "fan" })]), - ); - const user = userEvent.setup(); - renderWithContexts( - {}} - />, - ); - await user.click(screen.getByText("1")); - expect(onCast).not.toHaveBeenCalled(); - expect(await screen.findByRole("menuitem", { name: /fan/ })) - .toHaveAttribute( - "href", - "/users/fan", - ); - }); - - test("likes work the same way", async () => { - const onLike = vi.fn(); - const onUnlike = vi.fn(); - route("GET /api/comments/:id/likers", () => page([])); - const user = userEvent.setup(); - const { rerender } = renderWithContexts( - , - ); - await user.click(screen.getByRole("button", { name: "Like" })); - expect(onLike).toHaveBeenCalledWith("c", 2); - await user.click(screen.getByText("2")); - expect(await screen.findByText("No one yet.")).toBeInTheDocument(); - rerender( - , - ); - await user.click(screen.getByRole("button", { name: "Remove like" })); - expect(onUnlike).toHaveBeenCalledWith("c", 2); - }); -}); - -describe("UserListPopover", () => { - function Harness({ onClose = () => {} }: { onClose?: () => void }) { - const ref = useRef(null); - return ( - <> - anchor - - - - ); - } - - test("pages through the list", async () => { - route( - "GET /api/x/voters", - (req) => - req.url.searchParams.get("page") === "1" - ? page([makeUser({ username: "a" })], true) - : page([makeUser({ username: "b" })], false), - ); - const user = userEvent.setup(); - renderWithContexts(, { session: makeSession() }); - expect(screen.getByText("Loading…")).toBeInTheDocument(); - await screen.findByText("a"); - await user.click(screen.getByRole("button", { name: "Load more" })); - await screen.findByText("b"); - expect(screen.queryByRole("button", { name: "Load more" })).not - .toBeInTheDocument(); - expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch( - /^Bearer /, - ); - }); - - test("shows the server's error, and a network one", async () => { - route("GET /api/x/voters", () => fail("Dump not found", 404), { - once: true, - }); - const first = renderWithContexts(); - await screen.findByText("Dump not found"); - first.unmount(); - route("GET /api/x/voters", () => new TypeError("offline")); - renderWithContexts(); - await screen.findByText("Could not load."); - }); - - test("a failed next page lets you try again", async () => { - route( - "GET /api/x/voters", - () => page([makeUser({ username: "a" })], true), - { once: true }, - ); - route("GET /api/x/voters", () => new TypeError("offline"), { once: true }); - route( - "GET /api/x/voters", - () => page([makeUser({ username: "b" })], false), - ); - const user = userEvent.setup(); - renderWithContexts(); - await screen.findByText("a"); - await user.click(screen.getByRole("button", { name: "Load more" })); - const retry = await screen.findByRole("button", { name: "Load more" }); - expect(retry).toBeEnabled(); - await user.click(retry); - await screen.findByText("b"); - }); - - test("closes on Escape, an outside press and a scroll — not on the anchor", async () => { - route("GET /api/x/voters", () => page([])); - const onClose = vi.fn(); - const user = userEvent.setup(); - renderWithContexts(); - await screen.findByText("No one yet."); - await user.click(screen.getByText("anchor")); - await user.click(screen.getByText("No one yet.")); - expect(onClose).not.toHaveBeenCalled(); - await user.keyboard("{Escape}"); - await user.click(screen.getByText("outside")); - act(() => { - globalThis.dispatchEvent(new Event("scroll")); - }); - expect(onClose).toHaveBeenCalledTimes(3); - }); -}); - -describe("FollowButton", () => { - const me = makeSession({ id: "me" }); - - test("hidden for guests and on yourself", () => { - const { unmount } = renderWithContexts( - , - ); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); - unmount(); - renderWithContexts( - , - { session: me }, - ); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); - }); - - test("follow and unfollow a user", async () => { - const followUser = vi.fn(); - const unfollowUser = vi.fn(); - const user = userEvent.setup(); - renderWithContexts( - , - { session: me, follows: { followUser, unfollowUser } }, - ); - const btn = screen.getByRole("button", { name: "Follow ann" }); - expect(btn).toHaveTextContent("Follow"); - await user.click(btn); - expect(followUser).toHaveBeenCalledWith("u"); - expect(unfollowUser).not.toHaveBeenCalled(); - }); - - test("shows Following, and unfollows", async () => { - const unfollowUser = vi.fn(); - const user = userEvent.setup(); - renderWithContexts( - , - { - session: me, - follows: { followedUserIds: new Set(["u"]), unfollowUser }, - }, - ); - const btn = screen.getByRole("button", { name: "Unfollow ann" }); - expect(btn).toHaveTextContent("Following"); - await user.click(btn); - expect(unfollowUser).toHaveBeenCalledWith("u"); - }); - - test("disabled until follows have loaded", () => { - renderWithContexts( - , - { - session: me, - follows: { isLoaded: false }, - }, - ); - expect(screen.getByRole("button")).toBeDisabled(); - }); - - test("playlists: only public ones can be followed", async () => { - const followPlaylist = vi.fn(); - const unfollowPlaylist = vi.fn(); - const user = userEvent.setup(); - const priv = renderWithContexts( - , - { session: me }, - ); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); - priv.unmount(); - renderWithContexts(, { - session: me, - follows: { - followPlaylist, - unfollowPlaylist, - followedPlaylistIds: new Set(["p"]), - }, - }); - await user.click(screen.getByRole("button", { name: "Unfollow playlist" })); - expect(unfollowPlaylist).toHaveBeenCalledWith("p"); - }); -}); - -describe("NotificationBell / ChatButton", () => { - test("the bell shows the unread count and goes to notifications", async () => { - const user = userEvent.setup(); - renderWithContexts(, { - ws: { unreadNotificationCount: 120 }, - }); - const bell = screen.getByRole("button", { - name: "Notifications (120 unread)", - }); - expect(bell).toHaveTextContent("99+"); - await user.click(bell); - expect(currentPath()).toBe("/notifications"); - }); - - test("the bell rings for a new notification, not for one already there", () => { - vi.useFakeTimers(); - try { - const n1 = makeNotification({ id: "n1" }); - const withLast = (n: ReturnType) => ( - - - - ); - const { rerender } = renderWithContexts(withLast(n1)); - const bell = () => screen.getByRole("button", { name: "Notifications" }); - act(() => vi.advanceTimersByTime(10)); - expect(bell()).not.toHaveClass("notification-bell--ringing"); - rerender(withLast(makeNotification({ id: "n2" }))); - act(() => vi.advanceTimersByTime(10)); - expect(bell()).toHaveClass("notification-bell--ringing"); - act(() => vi.advanceTimersByTime(700)); - expect(bell()).not.toHaveClass("notification-bell--ringing"); - } finally { - vi.useRealTimers(); - } - }); - - test("the chat button opens the chat and shows unread", async () => { - const openChat = vi.fn(); - const user = userEvent.setup(); - renderWithContexts( - {} }} - > - - , - { ws: { unreadChatCount: 4 } }, - ); - await user.click(screen.getByRole("button", { name: "Chat (4 unread)" })); - expect(openChat).toHaveBeenCalled(); - await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument()); - }); -}); diff --git a/src/test/components/category/CategorySelect.test.tsx b/src/test/components/category/CategorySelect.test.tsx new file mode 100644 index 00000000..aaefd625 --- /dev/null +++ b/src/test/components/category/CategorySelect.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CategorySelect } from "../../../components/category/CategorySelect.tsx"; +import type { Category } from "../../../model.ts"; +import { makeCategory } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; + +const categories: Category[] = [ + makeCategory({ id: "c1", slug: "music", name: "Music" }), + makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }), +]; + +describe("CategorySelect", () => { + test("toggle chips reflect the selection", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( + , + { + categories, + }, + ); + expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute( + "aria-pressed", + "false", + ); + await user.click(screen.getByRole("button", { name: "Music" })); + expect(onToggle).toHaveBeenCalledWith("c1"); + }); + + test("nothing without categories", () => { + renderWithContexts( + {}} />, + ); + expect(screen.queryByText("Categories")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/components/category/CategorySwitcher.test.tsx b/src/test/components/category/CategorySwitcher.test.tsx new file mode 100644 index 00000000..2e5fe793 --- /dev/null +++ b/src/test/components/category/CategorySwitcher.test.tsx @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CategorySwitcher } from "../../../components/category/CategorySwitcher.tsx"; +import type { Category } from "../../../model.ts"; +import { makeCategory } from "../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../render.tsx"; + +const categories: Category[] = [ + makeCategory({ id: "c1", slug: "music", name: "Music" }), + makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }), +]; + +describe("CategorySwitcher", () => { + test("hidden with no categories", () => { + renderWithContexts(, { + route: "/~/hot", + path: "/:x/:feedTab", + }); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + test("switches scope, keeping the tab", async () => { + const user = userEvent.setup(); + renderWithContexts(, { + categories, + route: "/music/new", + path: "/:categorySlug/:feedTab", + }); + const trigger = screen.getByRole("button", { name: "Music" }); + await user.click(trigger); + expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute( + "aria-selected", + "true", + ); + await user.click(screen.getByRole("option", { name: "Cinema" })); + expect(currentPath()).toBe("/cinema/new"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + test("All is the ~ scope; an unknown slug shows as itself", async () => { + const user = userEvent.setup(); + renderWithContexts(, { + categories, + route: "/gone/hot", + path: "/:categorySlug/:feedTab", + }); + await user.click(screen.getByRole("button", { name: "gone" })); + await user.click(screen.getByRole("option", { name: "All" })); + expect(currentPath()).toBe("/~/hot"); + }); + + test("Escape closes the menu", async () => { + const user = userEvent.setup(); + renderWithContexts(, { + categories, + route: "/~/hot", + path: "/~/:feedTab", + }); + await user.click(screen.getByRole("button", { name: "All" })); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/components/ChatModal.test.tsx b/src/test/components/chat/ChatModal.test.tsx similarity index 97% rename from src/test/components/ChatModal.test.tsx rename to src/test/components/chat/ChatModal.test.tsx index b03757f6..c17a1b8d 100644 --- a/src/test/components/ChatModal.test.tsx +++ b/src/test/components/chat/ChatModal.test.tsx @@ -1,12 +1,12 @@ import { describe, expect, test, vi } from "vitest"; import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { ChatModal } from "../../components/ChatModal.tsx"; -import type { AuthResponse, ChatMessage } from "../../model.ts"; -import { calls, fail, route } from "../fetch.ts"; -import { makeChatMessage, makeSession, wire } from "../fixtures.ts"; -import { renderApp } from "../render.tsx"; -import { FakeWebSocket } from "../ws.ts"; +import { ChatModal } from "../../../components/chat/ChatModal.tsx"; +import type { AuthResponse, ChatMessage } from "../../../model.ts"; +import { calls, fail, route } from "../../fetch.ts"; +import { makeChatMessage, makeSession, wire } from "../../fixtures.ts"; +import { renderApp } from "../../render.tsx"; +import { FakeWebSocket } from "../../ws.ts"; const me = makeSession({ id: "me", username: "me" }); diff --git a/src/test/components/CommentThread.test.tsx b/src/test/components/comment/CommentThread.test.tsx similarity index 96% rename from src/test/components/CommentThread.test.tsx rename to src/test/components/comment/CommentThread.test.tsx index 1fafd072..866597c7 100644 --- a/src/test/components/CommentThread.test.tsx +++ b/src/test/components/comment/CommentThread.test.tsx @@ -2,11 +2,11 @@ import { describe, expect, test, vi } from "vitest"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; -import { CommentThread } from "../../components/CommentThread.tsx"; -import type { AuthResponse, Comment } from "../../model.ts"; -import { calls, fail, route } from "../fetch.ts"; -import { makeComment, makeSession, wire } from "../fixtures.ts"; -import { renderWithContexts } from "../render.tsx"; +import { CommentThread } from "../../../components/comment/CommentThread.tsx"; +import type { AuthResponse, Comment } from "../../../model.ts"; +import { calls, fail, route } from "../../fetch.ts"; +import { makeComment, makeSession, wire } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; const me = makeSession({ id: "me", username: "me" }); const mod = makeSession({ id: "mod", username: "mod", role: "moderator" }); diff --git a/src/test/components/content.test.tsx b/src/test/components/content.test.tsx deleted file mode 100644 index 1bf136fa..00000000 --- a/src/test/components/content.test.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { fireEvent, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Markdown } from "../../components/Markdown.tsx"; -import Thumbnail from "../../components/Thumbnail.tsx"; -import ThumbnailPlaceholder from "../../components/ThumbnailPlaceholder.tsx"; -import RichContentCard from "../../components/RichContentCard.tsx"; -import { DumpCard } from "../../components/DumpCard.tsx"; -import { hueFromString } from "../../utils/thumbnailTint.ts"; -import { markDumpVisited } from "../../utils/visited.ts"; -import { makeCategory, makeDump, makeSession } from "../fixtures.ts"; -import { currentPath, renderWithContexts } from "../render.tsx"; - -describe("Markdown", () => { - const md = (text: string, inline = false) => - renderWithContexts({text}); - - test("renders GFM", () => { - md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done"); - expect(screen.getByText("bold").tagName).toBe("STRONG"); - expect(screen.getByText("gone").tagName).toBe("DEL"); - expect(screen.getByRole("table").parentElement).toHaveClass( - "md-table-wrap", - ); - expect(screen.getByRole("checkbox")).toBeChecked(); - }); - - test("@mentions link to the profile, in-app", async () => { - md("hi @ann and bob@mail.com"); - const link = screen.getByRole("link", { name: "@ann" }); - expect(link).toHaveAttribute("href", "/users/ann"); - expect(link).not.toHaveAttribute("target"); - // GFM autolinks the address itself; it isn't a mention of "mail". - expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute( - "href", - "mailto:bob@mail.com", - ); - expect(document.querySelector('a[href="/users/mail"]')).toBeNull(); - await userEvent.click(link); - expect(currentPath()).toBe("/users/ann"); - }); - - test("mentions in code and inside URLs are left alone", () => { - md("`@ann` and https://mastodon.social/@bob"); - expect(screen.getByText("@ann").tagName).toBe("CODE"); - expect(screen.getByRole("link")).toHaveAttribute( - "href", - "https://mastodon.social/@bob", - ); - }); - - test("external links open in a new tab; internal and same-origin ones don't", () => { - md( - `[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`, - ); - expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute( - "target", - "_blank", - ); - expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute( - "rel", - "noopener noreferrer", - ); - expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute( - "target", - ); - expect(screen.getByRole("link", { name: "same" })).toHaveAttribute( - "href", - "/playlists/p?x=1#y", - ); - expect(screen.getByRole("link", { name: "file" })).toHaveAttribute( - "target", - "_blank", - ); - }); - - test("a single newline is a line break", () => { - const { container } = md("one\ntwo\nthree"); - expect(container.querySelectorAll("br")).toHaveLength(2); - }); - - test("code blocks keep their newlines", () => { - const { container } = md("```\na\nb\n```"); - expect(container.querySelector("pre")!.textContent).toBe("a\nb\n"); - expect(container.querySelectorAll("br")).toHaveLength(0); - }); - - test("images load lazily; inline teasers drop them", () => { - const full = md("![cat](https://x.test/cat.png)"); - expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute( - "loading", - "lazy", - ); - full.unmount(); - md("![cat](https://x.test/cat.png) text", true); - expect(screen.queryByRole("img")).not.toBeInTheDocument(); - expect(screen.getByText("text")).toBeInTheDocument(); - }); - - test("raw HTML is not rendered", () => { - const { container } = md("x"); - expect(container.querySelector("script")).toBeNull(); - expect(container.querySelector("b")).toBeNull(); - }); - - test("javascript: links are neutralised", () => { - md("[click](javascript:alert(1))"); - const link = screen.getByText("click").closest("a"); - expect(link?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i); - }); -}); - -describe("Thumbnail", () => { - const placeholder = { url: "https://site.test/page", siteName: "Site" }; - - test("direct, then proxied, then the placeholder", () => { - renderWithContexts( - , - ); - const img = screen.getByRole("img", { name: "art" }); - expect(img).toHaveAttribute("src", "https://img.test/a.png"); - expect(img).toHaveAttribute("referrerpolicy", "no-referrer"); - fireEvent.error(img); - expect(screen.getByRole("img", { name: "art" }).getAttribute("src")) - .toContain( - "/api/proxy-image?url=", - ); - fireEvent.error(screen.getByRole("img", { name: "art" })); - expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument(); - expect(screen.getByText("S")).toBeInTheDocument(); - }); - - test("our own images skip the proxy retry", () => { - renderWithContexts( - , - ); - fireEvent.error(screen.getByRole("img", { name: "t" })); - expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); - }); - - test("no src: straight to the placeholder; a new src tries again", () => { - const { rerender } = renderWithContexts( - , - ); - expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); - rerender( - , - ); - expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument(); - }); -}); - -describe("ThumbnailPlaceholder", () => { - test("tints by the page colour, or by hostname", () => { - const { container, rerender } = renderWithContexts( - , - ); - const box = () => - container.querySelector(".thumb-placeholder")!; - expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456"); - rerender( - , - ); - expect(box().style.getPropertyValue("--thumb-tint")).toBe( - hueFromString("site.test"), - ); - }); - - test("favicon, then the initial when it fails; a glyph wins over the initial", () => { - const { container, rerender } = renderWithContexts( - , - ); - const icon = container.querySelector("img")!; - fireEvent.error(icon); - expect(container.querySelector("img")).toBeNull(); - expect(screen.getByText("S")).toBeInTheDocument(); - rerender(); - expect(screen.getByText("🎬")).toBeInTheDocument(); - }); -}); - -describe("RichContentCard", () => { - const yt = { - type: "youtube", - url: "https://youtube.com/watch?v=x", - title: "A video", - siteName: "YouTube", - description: "About it", - thumbnailUrl: "https://i.ytimg.com/x.jpg", - embedUrl: "https://www.youtube.com/embed/x", - }; - - test("full card: play button, source link and details", async () => { - const play = vi.fn(); - renderWithContexts( - , - { - player: { play }, - }, - ); - expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute( - "href", - yt.url, - ); - expect(screen.getByText("About it")).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Play" })); - expect(play).toHaveBeenCalledWith(expect.objectContaining({ - kind: "embed", - embedUrl: yt.embedUrl, - dumpHref: "/dumps/d", - })); - }); - - test("compact: shows Pause while this card plays", () => { - renderWithContexts(, { - player: { - playing: true, - current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" }, - }, - }); - expect(screen.getByRole("button", { name: "Pause" })).toHaveClass( - "is-playing", - ); - }); - - test("compact and unplayable: a link to the source", () => { - renderWithContexts( - , - ); - expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute( - "href", - "https://blog.test/p", - ); - }); - - test("a dump thumbnail overrides the provider's", () => { - renderWithContexts( - , - ); - expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute( - "src", - "http://localhost:8000/api/thumbnails/d", - ); - }); - - test("a native stream that is the active item toggles instead of restarting", async () => { - const togglePlay = vi.fn(); - const play = vi.fn(); - const bc = { - type: "bandcamp", - url: "https://a.bandcamp.com/album/x", - embedUrl: "https://bandcamp.com/EmbeddedPlayer/1", - }; - renderWithContexts(, { - player: { - togglePlay, - play, - playing: true, - current: { - kind: "stream", - type: "bandcamp", - streamUrl: "s", - resolveUrl: bc.url, - resolveIndex: 0, - resolvedAt: 0, - }, - }, - }); - await userEvent.click(screen.getByRole("button", { name: "Pause" })); - expect(togglePlay).toHaveBeenCalled(); - expect(play).not.toHaveBeenCalled(); - }); -}); - -describe("DumpCard", () => { - const props = { - voteCount: 3, - voted: false, - canVote: true, - castVote: vi.fn(), - removeVote: vi.fn(), - }; - - test("title, teaser, date, comment count and categories", () => { - const dump = makeDump({ - id: "d1", - slug: "nice", - title: "Nice find", - comment: "Look **here**", - commentCount: 2, - categoryIds: ["c1", "missing"], - }); - renderWithContexts( -
    - -
, - { - categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })], - }, - ); - expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute( - "href", - "/dumps/nice", - ); - expect(screen.getByText("here").tagName).toBe("STRONG"); - expect(screen.getByText("2 comments")).toBeInTheDocument(); - expect(screen.getByText("1 minute ago")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute( - "href", - "/music", - ); - }); - - test("an unread dot until visited, never on your own", () => { - const dump = makeDump({ id: "d2" }); - const first = renderWithContexts( -
    - -
, - ); - expect(first.container.querySelector(".unread-dot")).not.toBeNull(); - first.unmount(); - const own = renderWithContexts( -
    - -
, - ); - expect(own.container.querySelector(".unread-dot")).toBeNull(); - own.unmount(); - markDumpVisited("d2"); - const seen = renderWithContexts( -
    - -
, - ); - expect(seen.container.querySelector(".unread-dot")).toBeNull(); - }); - - test("the private badge shows to the owner", () => { - renderWithContexts( -
    - -
, - ); - expect(screen.getByText("private")).toBeInTheDocument(); - }); - - test("clicking the card opens the dump; voting doesn't", async () => { - const castVote = vi.fn(); - const dump = makeDump({ id: "d3" }); - renderWithContexts( -
    - -
, - { session: makeSession() }, - ); - await userEvent.click(screen.getByRole("button", { name: "Upvote" })); - expect(castVote).toHaveBeenCalledWith("d3", 3); - expect(currentPath()).toBe("/"); - await userEvent.click(screen.getByText("1 minute ago")); - expect(currentPath()).toBe("/dumps/d3"); - }); - - test("file dumps get a file preview; bare URLs a placeholder", () => { - const { container } = renderWithContexts( -
    - - -
, - ); - expect(container.querySelectorAll(".thumb-placeholder").length) - .toBeGreaterThan(0); - const cards = container.querySelectorAll(".dump-card"); - expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument(); - }); -}); diff --git a/src/test/components/dump/DumpCard.test.tsx b/src/test/components/dump/DumpCard.test.tsx new file mode 100644 index 00000000..8bdec961 --- /dev/null +++ b/src/test/components/dump/DumpCard.test.tsx @@ -0,0 +1,119 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DumpCard } from "../../../components/dump/DumpCard.tsx"; +import { markDumpVisited } from "../../../utils/visited.ts"; +import { makeCategory, makeDump, makeSession } from "../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../render.tsx"; + +describe("DumpCard", () => { + const props = { + voteCount: 3, + voted: false, + canVote: true, + castVote: vi.fn(), + removeVote: vi.fn(), + }; + + test("title, teaser, date, comment count and categories", () => { + const dump = makeDump({ + id: "d1", + slug: "nice", + title: "Nice find", + comment: "Look **here**", + commentCount: 2, + categoryIds: ["c1", "missing"], + }); + renderWithContexts( +
    + +
, + { + categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })], + }, + ); + expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute( + "href", + "/dumps/nice", + ); + expect(screen.getByText("here").tagName).toBe("STRONG"); + expect(screen.getByText("2 comments")).toBeInTheDocument(); + expect(screen.getByText("1 minute ago")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute( + "href", + "/music", + ); + }); + + test("an unread dot until visited, never on your own", () => { + const dump = makeDump({ id: "d2" }); + const first = renderWithContexts( +
    + +
, + ); + expect(first.container.querySelector(".unread-dot")).not.toBeNull(); + first.unmount(); + const own = renderWithContexts( +
    + +
, + ); + expect(own.container.querySelector(".unread-dot")).toBeNull(); + own.unmount(); + markDumpVisited("d2"); + const seen = renderWithContexts( +
    + +
, + ); + expect(seen.container.querySelector(".unread-dot")).toBeNull(); + }); + + test("the private badge shows to the owner", () => { + renderWithContexts( +
    + +
, + ); + expect(screen.getByText("private")).toBeInTheDocument(); + }); + + test("clicking the card opens the dump; voting doesn't", async () => { + const castVote = vi.fn(); + const dump = makeDump({ id: "d3" }); + renderWithContexts( +
    + +
, + { session: makeSession() }, + ); + await userEvent.click(screen.getByRole("button", { name: "Upvote" })); + expect(castVote).toHaveBeenCalledWith("d3", 3); + expect(currentPath()).toBe("/"); + await userEvent.click(screen.getByText("1 minute ago")); + expect(currentPath()).toBe("/dumps/d3"); + }); + + test("file dumps get a file preview; bare URLs a placeholder", () => { + const { container } = renderWithContexts( +
    + + +
, + ); + expect(container.querySelectorAll(".thumb-placeholder").length) + .toBeGreaterThan(0); + const cards = container.querySelectorAll(".dump-card"); + expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument(); + }); +}); diff --git a/src/test/components/dump/FilePreview.test.tsx b/src/test/components/dump/FilePreview.test.tsx new file mode 100644 index 00000000..937b99a7 --- /dev/null +++ b/src/test/components/dump/FilePreview.test.tsx @@ -0,0 +1,119 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FilePreview } from "../../../components/dump/FilePreview.tsx"; +import { makeDump, makeSession } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; +import { + dumpFileUrl, + dumpFileVersion, + dumpThumbnailUrl, +} from "../../../utils/urls.ts"; + +describe("FilePreview", () => { + const session = makeSession(); + const file = (mime: string, over = {}) => + makeDump({ + id: "f1", + kind: "file", + fileName: "thing", + fileMime: mime, + fileSize: 2048, + url: undefined, + ...over, + }); + + test("images show inline, with the media token", () => { + const d = file("image/png"); + renderWithContexts(, { session }); + expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute( + "src", + `http://localhost:8000/api/files/f1?v=${ + dumpFileVersion(d) + }&token=${session.mediaToken}`, + ); + }); + + test("a compact image shows the server's preview, not the upload", () => { + const d = file("image/png"); + renderWithContexts(, { session }); + const img = screen.getByRole("img", { name: "thing" }); + expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken)); + expect(img).toHaveAttribute("loading", "lazy"); + }); + + test("other files are a download link", () => { + renderWithContexts(); + const link = screen.getByRole("link", { name: /Download thing/ }); + expect(link).toHaveAttribute("download", "thing"); + expect(link).toHaveTextContent("(2.0 KB)"); + }); + + test("compact audio plays in the global player", async () => { + const play = vi.fn(); + renderWithContexts(, { + player: { play }, + }); + await userEvent.click(screen.getByRole("button", { name: "Play" })); + expect(play).toHaveBeenCalledWith(expect.objectContaining({ + kind: "file", + mimeType: "audio/mpeg", + title: "Dump f1", + subtitle: "thing", + dumpHref: "/dumps/f1", + artworkUrl: undefined, + })); + }); + + test("compact video claims its generated still as artwork", async () => { + const play = vi.fn(); + const d = file("video/mp4"); + renderWithContexts(, { + player: { play }, + }); + await userEvent.click(screen.getByRole("button", { name: "Play" })); + expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d)); + expect(play.mock.calls[0][0].artworkUrl).toMatch( + /^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/, + ); + }); + + test("the global video button toggles when it's the one playing", async () => { + const togglePlay = vi.fn(); + const d = file("video/mp4"); + renderWithContexts(, { + player: { + togglePlay, + playing: true, + current: { + kind: "file", + fileUrl: dumpFileUrl(d), + mimeType: "video/mp4", + }, + }, + }); + await userEvent.click(screen.getByRole("button", { name: "Pause" })); + expect(togglePlay).toHaveBeenCalled(); + }); + + test("the global audio waveform starts playback", async () => { + const play = vi.fn(); + route("GET /api/files/:id", () => new TypeError("no audio in jsdom")); + renderWithContexts(, { + player: { play }, + }); + await userEvent.click(screen.getByRole("button", { name: "Play" })); + expect(play).toHaveBeenCalledWith( + expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }), + ); + }); + + test("compact PDFs and text get an icon", () => { + const { container } = renderWithContexts( + , + ); + expect(container).toHaveTextContent("📄"); + }); +}); + +import { route } from "../../fetch.ts"; diff --git a/src/test/components/dump/JournalCard.test.tsx b/src/test/components/dump/JournalCard.test.tsx new file mode 100644 index 00000000..9790cf2b --- /dev/null +++ b/src/test/components/dump/JournalCard.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, test, vi } from "vitest"; +import { within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { JournalCard } from "../../../components/dump/JournalCard.tsx"; +import { makeDump } from "../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../render.tsx"; +import { dumpThumbnailUrl } from "../../../utils/urls.ts"; + +describe("JournalCard", () => { + const base = { + voteCount: 1, + voted: false, + canVote: true, + castVote: vi.fn(), + removeVote: vi.fn(), + }; + + test("image, quote and text modes", () => { + const { container, unmount } = renderWithContexts( +
    + + + +
, + ); + const cards = container.querySelectorAll("li"); + expect(cards[0]).toHaveClass( + "journal-card--image", + "journal-card--feature", + ); + expect(cards[1]).toHaveClass("journal-card--quote"); + expect(within(cards[1] as HTMLElement).getByText("A thought")) + .toBeInTheDocument(); + expect(cards[2]).toHaveClass("journal-card--text"); + expect(cards[2]).toHaveTextContent("🔗"); + unmount(); + }); + + test("a playable image card plays; an unplayable one navigates", async () => { + const play = vi.fn(); + const user = userEvent.setup(); + const yt = makeDump({ + id: "y", + richContent: { + type: "youtube", + url: "https://youtu.be/y", + thumbnailUrl: "https://i.test/y.png", + embedUrl: "https://www.youtube.com/embed/y", + }, + }); + const pic = makeDump({ + id: "p", + kind: "file", + fileMime: "image/png", + fileName: "p.png", + url: undefined, + }); + const { container } = renderWithContexts( +
    + + +
, + { player: { play } }, + ); + const cards = container.querySelectorAll("li"); + await user.click(cards[0].querySelector(".journal-card-image")!); + expect(play).toHaveBeenCalledWith( + expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }), + ); + expect(currentPath()).toBe("/"); + await user.click(cards[1].querySelector(".journal-card-image")!); + expect(currentPath()).toBe("/dumps/p"); + }); + + test("a video file shows its generated still", () => { + const dump = makeDump({ + id: "v", + kind: "file", + fileMime: "video/mp4", + url: undefined, + }); + const { container } = renderWithContexts( +
    + +
, + ); + expect(container.querySelector("img")!.getAttribute("src")).toBe( + dumpThumbnailUrl(dump), + ); + }); +}); diff --git a/src/test/components/dump/RichContentCard.test.tsx b/src/test/components/dump/RichContentCard.test.tsx new file mode 100644 index 00000000..f294d96d --- /dev/null +++ b/src/test/components/dump/RichContentCard.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { RichContentCard } from "../../../components/dump/RichContentCard.tsx"; +import { renderWithContexts } from "../../render.tsx"; + +describe("RichContentCard", () => { + const yt = { + type: "youtube", + url: "https://youtube.com/watch?v=x", + title: "A video", + siteName: "YouTube", + description: "About it", + thumbnailUrl: "https://i.ytimg.com/x.jpg", + embedUrl: "https://www.youtube.com/embed/x", + }; + + test("full card: play button, source link and details", async () => { + const play = vi.fn(); + renderWithContexts( + , + { + player: { play }, + }, + ); + expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute( + "href", + yt.url, + ); + expect(screen.getByText("About it")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Play" })); + expect(play).toHaveBeenCalledWith(expect.objectContaining({ + kind: "embed", + embedUrl: yt.embedUrl, + dumpHref: "/dumps/d", + })); + }); + + test("compact: shows Pause while this card plays", () => { + renderWithContexts(, { + player: { + playing: true, + current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" }, + }, + }); + expect(screen.getByRole("button", { name: "Pause" })).toHaveClass( + "is-playing", + ); + }); + + test("compact and unplayable: a link to the source", () => { + renderWithContexts( + , + ); + expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute( + "href", + "https://blog.test/p", + ); + }); + + test("a dump thumbnail overrides the provider's", () => { + renderWithContexts( + , + ); + expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute( + "src", + "http://localhost:8000/api/thumbnails/d", + ); + }); + + test("a native stream that is the active item toggles instead of restarting", async () => { + const togglePlay = vi.fn(); + const play = vi.fn(); + const bc = { + type: "bandcamp", + url: "https://a.bandcamp.com/album/x", + embedUrl: "https://bandcamp.com/EmbeddedPlayer/1", + }; + renderWithContexts(, { + player: { + togglePlay, + play, + playing: true, + current: { + kind: "stream", + type: "bandcamp", + streamUrl: "s", + resolveUrl: bc.url, + resolveIndex: 0, + resolvedAt: 0, + }, + }, + }); + await userEvent.click(screen.getByRole("button", { name: "Pause" })); + expect(togglePlay).toHaveBeenCalled(); + expect(play).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/components/DumpCreateModal.test.tsx b/src/test/components/dump/create/DumpCreateModal.test.tsx similarity index 98% rename from src/test/components/DumpCreateModal.test.tsx rename to src/test/components/dump/create/DumpCreateModal.test.tsx index 33c85214..0a88ff91 100644 --- a/src/test/components/DumpCreateModal.test.tsx +++ b/src/test/components/dump/create/DumpCreateModal.test.tsx @@ -7,16 +7,16 @@ import { within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { DumpCreateModal } from "../../components/DumpCreateModal.tsx"; -import { calls, fail, route } from "../fetch.ts"; +import { DumpCreateModal } from "../../../../components/dump/create/DumpCreateModal.tsx"; +import { calls, fail, route } from "../../../fetch.ts"; import { makeCategory, makeDump, makePlaylist, makeSession, wire, -} from "../fixtures.ts"; -import { currentPath, renderWithContexts } from "../render.tsx"; +} from "../../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../../render.tsx"; const session = makeSession({ username: "me" }); const categories = [makeCategory({ id: "c1", name: "Music" })]; diff --git a/src/test/components/editor/Markdown.test.tsx b/src/test/components/editor/Markdown.test.tsx new file mode 100644 index 00000000..06e01e27 --- /dev/null +++ b/src/test/components/editor/Markdown.test.tsx @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Markdown } from "../../../components/editor/Markdown.tsx"; +import { currentPath, renderWithContexts } from "../../render.tsx"; + +describe("Markdown", () => { + const md = (text: string, inline = false) => + renderWithContexts({text}); + + test("renders GFM", () => { + md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done"); + expect(screen.getByText("bold").tagName).toBe("STRONG"); + expect(screen.getByText("gone").tagName).toBe("DEL"); + expect(screen.getByRole("table").parentElement).toHaveClass( + "md-table-wrap", + ); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + test("@mentions link to the profile, in-app", async () => { + md("hi @ann and bob@mail.com"); + const link = screen.getByRole("link", { name: "@ann" }); + expect(link).toHaveAttribute("href", "/users/ann"); + expect(link).not.toHaveAttribute("target"); + // GFM autolinks the address itself; it isn't a mention of "mail". + expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute( + "href", + "mailto:bob@mail.com", + ); + expect(document.querySelector('a[href="/users/mail"]')).toBeNull(); + await userEvent.click(link); + expect(currentPath()).toBe("/users/ann"); + }); + + test("mentions in code and inside URLs are left alone", () => { + md("`@ann` and https://mastodon.social/@bob"); + expect(screen.getByText("@ann").tagName).toBe("CODE"); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "https://mastodon.social/@bob", + ); + }); + + test("external links open in a new tab; internal and same-origin ones don't", () => { + md( + `[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`, + ); + expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute( + "target", + "_blank", + ); + expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute( + "rel", + "noopener noreferrer", + ); + expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute( + "target", + ); + expect(screen.getByRole("link", { name: "same" })).toHaveAttribute( + "href", + "/playlists/p?x=1#y", + ); + expect(screen.getByRole("link", { name: "file" })).toHaveAttribute( + "target", + "_blank", + ); + }); + + test("a single newline is a line break", () => { + const { container } = md("one\ntwo\nthree"); + expect(container.querySelectorAll("br")).toHaveLength(2); + }); + + test("code blocks keep their newlines", () => { + const { container } = md("```\na\nb\n```"); + expect(container.querySelector("pre")!.textContent).toBe("a\nb\n"); + expect(container.querySelectorAll("br")).toHaveLength(0); + }); + + test("images load lazily; inline teasers drop them", () => { + const full = md("![cat](https://x.test/cat.png)"); + expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute( + "loading", + "lazy", + ); + full.unmount(); + md("![cat](https://x.test/cat.png) text", true); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("text")).toBeInTheDocument(); + }); + + test("raw HTML is not rendered", () => { + const { container } = md("x"); + expect(container.querySelector("script")).toBeNull(); + expect(container.querySelector("b")).toBeNull(); + }); + + test("javascript: links are neutralised", () => { + md("[click](javascript:alert(1))"); + const link = screen.getByText("click").closest("a"); + expect(link?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i); + }); +}); diff --git a/src/test/components/TextEditor.test.tsx b/src/test/components/editor/TextEditor.test.tsx similarity index 97% rename from src/test/components/TextEditor.test.tsx rename to src/test/components/editor/TextEditor.test.tsx index f46d0a5e..7f9e01e9 100644 --- a/src/test/components/TextEditor.test.tsx +++ b/src/test/components/editor/TextEditor.test.tsx @@ -11,11 +11,11 @@ import { createRef, useState } from "react"; import { TextEditor, type TextEditorHandle, -} from "../../components/TextEditor.tsx"; -import { calls, deferred, fail, route } from "../fetch.ts"; -import { makeSession } from "../fixtures.ts"; -import { renderWithContexts } from "../render.tsx"; -import { serveEmojibase } from "../emojibase.ts"; +} from "../../../components/editor/TextEditor.tsx"; +import { calls, deferred, fail, route } from "../../fetch.ts"; +import { makeSession } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; +import { serveEmojibase } from "../../emojibase.ts"; type Props = Partial>; diff --git a/src/test/components/header.test.tsx b/src/test/components/header.test.tsx deleted file mode 100644 index efba6d11..00000000 --- a/src/test/components/header.test.tsx +++ /dev/null @@ -1,498 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; -import { act, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { I18nProvider } from "@lingui/react"; -import { i18n } from "@lingui/core"; -import { BrowserRouter, Route, Routes, useLocation } from "react-router"; -import { useState } from "react"; -import { SearchBar } from "../../components/SearchBar.tsx"; -import { UserMenu } from "../../components/UserMenu.tsx"; -import { PresenceRow } from "../../components/PresenceRow.tsx"; -import { CategorySwitcher } from "../../components/CategorySwitcher.tsx"; -import { CategorySelect } from "../../components/CategorySelect.tsx"; -import { TabBar } from "../../components/TabBar.tsx"; -import { FeedTabBar } from "../../components/FeedTabBar.tsx"; -import { AppHeader } from "../../components/AppHeader.tsx"; -import { ChatFab } from "../../components/ChatFab.tsx"; -import { DumpFab } from "../../components/DumpFab.tsx"; -import { Tooltip } from "../../components/Tooltip.tsx"; -import { CountedInput } from "../../components/CountedInput.tsx"; -import { PageError } from "../../components/PageError.tsx"; -import { AuthContext } from "../../contexts/AuthContext.ts"; -import { ChatContext } from "../../contexts/ChatContext.ts"; -import { defaultWSValue, WSContext } from "../../contexts/WSContext.ts"; -import type { AuthResponse, Category } from "../../model.ts"; -import { makeCategory, makeSession, makeUser } from "../fixtures.ts"; -import { currentPath, renderWithContexts } from "../render.tsx"; - -const categories: Category[] = [ - makeCategory({ id: "c1", slug: "music", name: "Music" }), - makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }), -]; - -describe("SearchBar", () => { - function Harness(props: Partial[0]>) { - const [value, setValue] = useState(""); - return ( - {}} - onCancel={() => {}} - {...props} - /> - ); - } - - test("submits the trimmed query; ignores a blank one", async () => { - const onSubmit = vi.fn(); - const user = userEvent.setup(); - renderWithContexts(); - const input = screen.getByRole("searchbox", { name: "Search" }); - await user.type(input, " {Enter}"); - expect(onSubmit).not.toHaveBeenCalled(); - await user.type(input, " cats {Enter}"); - expect(onSubmit).toHaveBeenCalledWith("cats"); - expect(input).not.toHaveFocus(); - }); - - test("Escape cancels instead of clearing", async () => { - const onCancel = vi.fn(); - const user = userEvent.setup(); - renderWithContexts(); - await user.type(screen.getByRole("searchbox"), "dogs{Escape}"); - expect(onCancel).toHaveBeenCalled(); - expect(screen.getByRole("searchbox")).toHaveValue("dogs"); - }); - - test("takes focus when it opens, not when it mounts open", () => { - const props = { - value: "", - onValueChange: () => {}, - onSubmit: () => {}, - onCancel: () => {}, - }; - const { rerender } = renderWithContexts(); - expect(screen.getByRole("searchbox")).not.toHaveFocus(); - rerender(); - expect(screen.getByRole("searchbox")).toHaveAttribute("tabindex", "-1"); - rerender(); - expect(screen.getByRole("searchbox")).toHaveFocus(); - rerender(); - expect(screen.getByRole("searchbox")).not.toHaveFocus(); - }); -}); - -describe("UserMenu", () => { - test("opens, links to profile and playlists, closes on Escape and outside", async () => { - const user = userEvent.setup(); - renderWithContexts( - <> - -

outside

- , - ); - const trigger = screen.getByRole("button", { name: "User menu" }); - await user.click(trigger); - expect(trigger).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute( - "href", - "/users/ann", - ); - expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute( - "href", - "/users/ann/playlists", - ); - await user.keyboard("{Escape}"); - expect(screen.queryByRole("menu")).not.toBeInTheDocument(); - await user.click(trigger); - await user.click(screen.getByText("outside")); - expect(screen.queryByRole("menu")).not.toBeInTheDocument(); - await user.click(trigger); - await user.click(screen.getByRole("menuitem", { name: "@ann" })); - expect(currentPath()).toBe("/users/ann"); - expect(screen.queryByRole("menu")).not.toBeInTheDocument(); - }); -}); - -describe("PresenceRow", () => { - const online = (n: number) => - Array.from({ length: n }, (_, i) => ({ - userId: `u${i}`, - username: `user${i}`, - hasAvatar: false, - })); - - test("nothing when nobody's online", () => { - renderWithContexts(); - expect(document.querySelector(".index-presence")).toBeNull(); - }); - - test("up to six seats, the rest folded into a counter", () => { - renderWithContexts(, { ws: { onlineUsers: online(9) } }); - expect(screen.getAllByRole("link")).toHaveLength(5); - const more = screen.getByRole("img", { name: /4 more online/ }); - expect(more).toHaveTextContent("+4"); - expect(more).toHaveAttribute("title", "user5, user6, user7, user8"); - }); - - test("six or fewer all show", () => { - renderWithContexts(, { ws: { onlineUsers: online(6) } }); - expect(screen.getAllByRole("link")).toHaveLength(6); - expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); - }); - - test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => { - const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get") - .mockReturnValue(100); - renderWithContexts( -
-
- a -
- -
, - { ws: { onlineUsers: online(9) } }, - ); - expect(screen.getAllByRole("link")).toHaveLength(3); - width.mockReturnValue(0); - act(() => { - globalThis.dispatchEvent(new Event("resize")); - }); - width.mockRestore(); - }); -}); - -describe("CategorySwitcher", () => { - test("hidden with no categories", () => { - renderWithContexts(, { - route: "/~/hot", - path: "/:x/:feedTab", - }); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); - }); - - test("switches scope, keeping the tab", async () => { - const user = userEvent.setup(); - renderWithContexts(, { - categories, - route: "/music/new", - path: "/:categorySlug/:feedTab", - }); - const trigger = screen.getByRole("button", { name: "Music" }); - await user.click(trigger); - expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute( - "aria-selected", - "true", - ); - await user.click(screen.getByRole("option", { name: "Cinema" })); - expect(currentPath()).toBe("/cinema/new"); - expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); - }); - - test("All is the ~ scope; an unknown slug shows as itself", async () => { - const user = userEvent.setup(); - renderWithContexts(, { - categories, - route: "/gone/hot", - path: "/:categorySlug/:feedTab", - }); - await user.click(screen.getByRole("button", { name: "gone" })); - await user.click(screen.getByRole("option", { name: "All" })); - expect(currentPath()).toBe("/~/hot"); - }); - - test("Escape closes the menu", async () => { - const user = userEvent.setup(); - renderWithContexts(, { - categories, - route: "/~/hot", - path: "/~/:feedTab", - }); - await user.click(screen.getByRole("button", { name: "All" })); - await user.keyboard("{Escape}"); - expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); - }); -}); - -describe("CategorySelect", () => { - test("toggle chips reflect the selection", async () => { - const onToggle = vi.fn(); - const user = userEvent.setup(); - renderWithContexts( - , - { - categories, - }, - ); - expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute( - "aria-pressed", - "true", - ); - expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute( - "aria-pressed", - "false", - ); - await user.click(screen.getByRole("button", { name: "Music" })); - expect(onToggle).toHaveBeenCalledWith("c1"); - }); - - test("nothing without categories", () => { - renderWithContexts( - {}} />, - ); - expect(screen.queryByText("Categories")).not.toBeInTheDocument(); - }); -}); - -describe("TabBar / FeedTabBar", () => { - test("TabBar marks the active tab and reports clicks", async () => { - const onChange = vi.fn(); - const user = userEvent.setup(); - renderWithContexts( - , - ); - expect(screen.getByRole("button", { name: "B" })).toHaveClass("active"); - await user.click(screen.getByRole("button", { name: "A" })); - expect(onChange).toHaveBeenCalledWith("a"); - }); - - test("guests don't get Followed; tabs navigate within the scope", async () => { - const user = userEvent.setup(); - renderWithContexts(, { - route: "/music/hot", - path: "/:categorySlug/:feedTab", - categories, - }); - expect(screen.queryByRole("button", { name: "Followed" })).not - .toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active"); - await user.click(screen.getByRole("button", { name: "Journal" })); - expect(currentPath()).toBe("/music/journal"); - }); - - test("signed in, Followed is there", () => { - renderWithContexts(, { - session: makeSession(), - route: "/~/followed", - path: "/~/:feedTab", - }); - expect(screen.getByRole("button", { name: "Followed" })).toHaveClass( - "active", - ); - }); -}); - -describe("AppHeader", () => { - test("guests get a login button", async () => { - const user = userEvent.setup(); - renderWithContexts(); - await user.click(screen.getByRole("button", { name: "Log in" })); - expect(currentPath()).toBe("/login"); - }); - - test("members get their links, chat, bell and the new-dump button", () => { - renderWithContexts(, { - session: makeSession({ username: "ann" }), - }); - expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute( - "href", - "/users/ann", - ); - expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Notifications" })) - .toBeInTheDocument(); - const dump = screen.getByRole("button", { name: /Dump/ }); - expect(dump).toBeDisabled(); - expect(dump).toHaveAttribute("title", "Server unreachable"); - }); - - test("shows the live-updates banner when disconnected", () => { - renderWithContexts(, { - ws: { wsStatus: "disconnected", wsErrorMessage: "Trying to reconnect…" }, - }); - expect(screen.getByRole("alert")).toHaveTextContent( - "Live updates unavailable. Trying to reconnect…", - ); - }); - - test("the search toggle opens and closes, giving focus back", async () => { - const user = userEvent.setup(); - renderWithContexts(); - const toggle = screen.getByRole("button", { name: "Search" }); - await user.click(toggle); - expect(screen.getByRole("button", { name: "Cancel search" })) - .toHaveAttribute( - "aria-expanded", - "true", - ); - const [input] = screen.getAllByRole("searchbox"); - await user.type(input, "x"); - await user.keyboard("{Escape}"); - expect(screen.getByRole("button", { name: "Search" })).toHaveFocus(); - expect(screen.getAllByRole("searchbox")[0]).toHaveValue(""); - }); - - // Leaving search needs the real history stack (React Router keeps its - // index in history.state), so these use a BrowserRouter. - function Browser({ session }: { session?: AuthResponse }) { - const [authResponse, setAuthResponse] = useState(session ?? null); - function Loc() { - const l = useLocation(); - return {l.pathname + l.search}; - } - return ( - - - - - - } /> - - - - - - - ); - } - const loc = () => screen.getByTestId("loc").textContent; - afterEach(() => window.history.replaceState(null, "", "/")); - - test("several searches, then ✕, return to where search began", async () => { - window.history.replaceState(null, "", "/start"); - const user = userEvent.setup(); - render(); - await user.click(screen.getByRole("button", { name: "Search" })); - await user.type(screen.getAllByRole("searchbox")[0], "cats{Enter}"); - expect(loc()).toBe("/search?q=cats"); - const bar = () => screen.getAllByRole("searchbox")[0]; - expect(bar()).toHaveValue("cats"); - await user.clear(bar()); - await user.type(bar(), "dogs{Enter}"); - expect(loc()).toBe("/search?q=dogs"); - await user.click(screen.getByRole("button", { name: "Cancel search" })); - await waitFor(() => expect(loc()).toBe("/start")); - }); - - test("Escape on the results page reverts the query instead of leaving", async () => { - window.history.replaceState(null, "", "/search?q=cats"); - const user = userEvent.setup(); - render(); - const bar = screen.getAllByRole("searchbox")[0]; - expect(bar).toHaveValue("cats"); - await user.type(bar, "zzz{Escape}"); - expect(bar).toHaveValue("cats"); - expect(loc()).toBe("/search?q=cats"); - }); - - test("✕ on results that opened the session goes home", async () => { - window.history.replaceState(null, "", "/search?q=cats"); - const user = userEvent.setup(); - render(); - await user.click(screen.getByRole("button", { name: "Cancel search" })); - expect(loc()).toBe("/"); - }); -}); - -describe("floating buttons", () => { - function withChat(openChat = vi.fn()) { - return (ui: React.ReactNode) => ( - {} }} - > - {ui} - - ); - } - - test("guests get neither", () => { - renderWithContexts( - withChat()( - <> - - - , - ), - ); - expect(screen.queryByRole("button", { hidden: true })).not - .toBeInTheDocument(); - }); - - test("they show once the header scrolls away", () => { - const openChat = vi.fn(); - renderWithContexts( - withChat(openChat)( - <> -
- - - , - ), - { session: makeSession(), ws: { unreadChatCount: 2 } }, - ); - const header = document.querySelector(".app-header")!; - // jsdom lays nothing out: put the header on screen first. - header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect; - act(() => { - globalThis.dispatchEvent(new Event("scroll")); - }); - const chat = document.querySelector(".chat-fab")!; - expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)"); - expect(chat).toHaveAttribute("aria-hidden", "true"); - expect(chat).toHaveAttribute("tabindex", "-1"); - expect(document.body).toHaveClass("has-fab"); - - header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect; - act(() => { - globalThis.dispatchEvent(new Event("scroll")); - }); - expect(chat).toHaveAttribute("aria-hidden", "false"); - expect(chat).toHaveClass("chat-fab--visible"); - act(() => chat.click()); - expect(openChat).toHaveBeenCalled(); - expect(screen.getByRole("button", { name: "New dump" })).toHaveClass( - "dump-fab--visible", - ); - }); -}); - -describe("small pieces", () => { - test("Tooltip shows on hover", async () => { - const user = userEvent.setup(); - renderWithContexts( - - hover me - , - ); - await user.hover(screen.getByText("hover me")); - expect(screen.getByText("Explained")).toBeInTheDocument(); - await user.unhover(screen.getByText("hover me")); - expect(screen.queryByText("Explained")).not.toBeInTheDocument(); - }); - - test("CountedInput counts toward its limit", () => { - renderWithContexts( - {}} - />, - ); - const count = screen.getByText("90 / 100"); - expect(count).toHaveClass("text-editor-count--warn"); - expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100"); - }); - - test("PageError wraps an alert in the page shell", () => { - renderWithContexts(); - expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong"); - expect(screen.getByRole("alert")).toHaveTextContent("Gone"); - expect(document.querySelector(".app-header")).not.toBeNull(); - }); -}); diff --git a/src/test/components/layout/AppHeader.test.tsx b/src/test/components/layout/AppHeader.test.tsx new file mode 100644 index 00000000..e3d1cddc --- /dev/null +++ b/src/test/components/layout/AppHeader.test.tsx @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { BrowserRouter, Route, Routes, useLocation } from "react-router"; +import { useState } from "react"; +import { AppHeader } from "../../../components/layout/AppHeader.tsx"; +import { AuthContext } from "../../../contexts/AuthContext.ts"; +import { defaultWSValue, WSContext } from "../../../contexts/WSContext.ts"; +import type { AuthResponse } from "../../../model.ts"; +import { makeSession } from "../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../render.tsx"; + +describe("AppHeader", () => { + test("guests get a login button", async () => { + const user = userEvent.setup(); + renderWithContexts(); + await user.click(screen.getByRole("button", { name: "Log in" })); + expect(currentPath()).toBe("/login"); + }); + + test("members get their links, chat, bell and the new-dump button", () => { + renderWithContexts(, { + session: makeSession({ username: "ann" }), + }); + expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute( + "href", + "/users/ann", + ); + expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Notifications" })) + .toBeInTheDocument(); + const dump = screen.getByRole("button", { name: /Dump/ }); + expect(dump).toBeDisabled(); + expect(dump).toHaveAttribute("title", "Server unreachable"); + }); + + test("shows the live-updates banner when disconnected", () => { + renderWithContexts(, { + ws: { wsStatus: "disconnected", wsErrorMessage: "Trying to reconnect…" }, + }); + expect(screen.getByRole("alert")).toHaveTextContent( + "Live updates unavailable. Trying to reconnect…", + ); + }); + + test("the search toggle opens and closes, giving focus back", async () => { + const user = userEvent.setup(); + renderWithContexts(); + const toggle = screen.getByRole("button", { name: "Search" }); + await user.click(toggle); + expect(screen.getByRole("button", { name: "Cancel search" })) + .toHaveAttribute( + "aria-expanded", + "true", + ); + const [input] = screen.getAllByRole("searchbox"); + await user.type(input, "x"); + await user.keyboard("{Escape}"); + expect(screen.getByRole("button", { name: "Search" })).toHaveFocus(); + expect(screen.getAllByRole("searchbox")[0]).toHaveValue(""); + }); + + // Leaving search needs the real history stack (React Router keeps its + // index in history.state), so these use a BrowserRouter. + function Browser({ session }: { session?: AuthResponse }) { + const [authResponse, setAuthResponse] = useState(session ?? null); + function Loc() { + const l = useLocation(); + return {l.pathname + l.search}; + } + return ( + + + + + + } /> + + + + + + + ); + } + const loc = () => screen.getByTestId("loc").textContent; + afterEach(() => window.history.replaceState(null, "", "/")); + + test("several searches, then ✕, return to where search began", async () => { + window.history.replaceState(null, "", "/start"); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Search" })); + await user.type(screen.getAllByRole("searchbox")[0], "cats{Enter}"); + expect(loc()).toBe("/search?q=cats"); + const bar = () => screen.getAllByRole("searchbox")[0]; + expect(bar()).toHaveValue("cats"); + await user.clear(bar()); + await user.type(bar(), "dogs{Enter}"); + expect(loc()).toBe("/search?q=dogs"); + await user.click(screen.getByRole("button", { name: "Cancel search" })); + await waitFor(() => expect(loc()).toBe("/start")); + }); + + test("Escape on the results page reverts the query instead of leaving", async () => { + window.history.replaceState(null, "", "/search?q=cats"); + const user = userEvent.setup(); + render(); + const bar = screen.getAllByRole("searchbox")[0]; + expect(bar).toHaveValue("cats"); + await user.type(bar, "zzz{Escape}"); + expect(bar).toHaveValue("cats"); + expect(loc()).toBe("/search?q=cats"); + }); + + test("✕ on results that opened the session goes home", async () => { + window.history.replaceState(null, "", "/search?q=cats"); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Cancel search" })); + expect(loc()).toBe("/"); + }); +}); diff --git a/src/test/components/layout/ChatButton.test.tsx b/src/test/components/layout/ChatButton.test.tsx new file mode 100644 index 00000000..30e59f3d --- /dev/null +++ b/src/test/components/layout/ChatButton.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ChatButton } from "../../../components/layout/ChatButton.tsx"; +import { ChatContext } from "../../../contexts/ChatContext.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("ChatButton", () => { + test("the chat button opens the chat and shows unread", async () => { + const openChat = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( + {} }} + > + + , + { ws: { unreadChatCount: 4 } }, + ); + await user.click(screen.getByRole("button", { name: "Chat (4 unread)" })); + expect(openChat).toHaveBeenCalled(); + await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument()); + }); +}); diff --git a/src/test/components/layout/Fabs.test.tsx b/src/test/components/layout/Fabs.test.tsx new file mode 100644 index 00000000..53e158c5 --- /dev/null +++ b/src/test/components/layout/Fabs.test.tsx @@ -0,0 +1,69 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, screen } from "@testing-library/react"; +import { ChatFab } from "../../../components/layout/ChatFab.tsx"; +import { DumpFab } from "../../../components/layout/DumpFab.tsx"; +import { ChatContext } from "../../../contexts/ChatContext.ts"; +import { makeSession } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("floating buttons", () => { + function withChat(openChat = vi.fn()) { + return (ui: React.ReactNode) => ( + {} }} + > + {ui} + + ); + } + + test("guests get neither", () => { + renderWithContexts( + withChat()( + <> + + + , + ), + ); + expect(screen.queryByRole("button", { hidden: true })).not + .toBeInTheDocument(); + }); + + test("they show once the header scrolls away", () => { + const openChat = vi.fn(); + renderWithContexts( + withChat(openChat)( + <> +
+ + + , + ), + { session: makeSession(), ws: { unreadChatCount: 2 } }, + ); + const header = document.querySelector(".app-header")!; + // jsdom lays nothing out: put the header on screen first. + header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect; + act(() => { + globalThis.dispatchEvent(new Event("scroll")); + }); + const chat = document.querySelector(".chat-fab")!; + expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)"); + expect(chat).toHaveAttribute("aria-hidden", "true"); + expect(chat).toHaveAttribute("tabindex", "-1"); + expect(document.body).toHaveClass("has-fab"); + + header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect; + act(() => { + globalThis.dispatchEvent(new Event("scroll")); + }); + expect(chat).toHaveAttribute("aria-hidden", "false"); + expect(chat).toHaveClass("chat-fab--visible"); + act(() => chat.click()); + expect(openChat).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "New dump" })).toHaveClass( + "dump-fab--visible", + ); + }); +}); diff --git a/src/test/components/layout/NotificationBell.test.tsx b/src/test/components/layout/NotificationBell.test.tsx new file mode 100644 index 00000000..1663819c --- /dev/null +++ b/src/test/components/layout/NotificationBell.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NotificationBell } from "../../../components/layout/NotificationBell.tsx"; +import { makeNotification } from "../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../render.tsx"; +import { defaultWSValue, WSContext } from "../../../contexts/WSContext.ts"; + +describe("NotificationBell", () => { + test("the bell shows the unread count and goes to notifications", async () => { + const user = userEvent.setup(); + renderWithContexts(, { + ws: { unreadNotificationCount: 120 }, + }); + const bell = screen.getByRole("button", { + name: "Notifications (120 unread)", + }); + expect(bell).toHaveTextContent("99+"); + await user.click(bell); + expect(currentPath()).toBe("/notifications"); + }); + + test("the bell rings for a new notification, not for one already there", () => { + vi.useFakeTimers(); + try { + const n1 = makeNotification({ id: "n1" }); + const withLast = (n: ReturnType) => ( + + + + ); + const { rerender } = renderWithContexts(withLast(n1)); + const bell = () => screen.getByRole("button", { name: "Notifications" }); + act(() => vi.advanceTimersByTime(10)); + expect(bell()).not.toHaveClass("notification-bell--ringing"); + rerender(withLast(makeNotification({ id: "n2" }))); + act(() => vi.advanceTimersByTime(10)); + expect(bell()).toHaveClass("notification-bell--ringing"); + act(() => vi.advanceTimersByTime(700)); + expect(bell()).not.toHaveClass("notification-bell--ringing"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/test/components/layout/PageError.test.tsx b/src/test/components/layout/PageError.test.tsx new file mode 100644 index 00000000..5fa25f6e --- /dev/null +++ b/src/test/components/layout/PageError.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import { PageError } from "../../../components/layout/PageError.tsx"; +import { renderWithContexts } from "../../render.tsx"; + +describe("PageError", () => { + test("PageError wraps an alert in the page shell", () => { + renderWithContexts(); + expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong"); + expect(screen.getByRole("alert")).toHaveTextContent("Gone"); + expect(document.querySelector(".app-header")).not.toBeNull(); + }); +}); diff --git a/src/test/components/layout/SearchBar.test.tsx b/src/test/components/layout/SearchBar.test.tsx new file mode 100644 index 00000000..63cde0b9 --- /dev/null +++ b/src/test/components/layout/SearchBar.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { SearchBar } from "../../../components/layout/SearchBar.tsx"; +import { renderWithContexts } from "../../render.tsx"; + +describe("SearchBar", () => { + function Harness(props: Partial[0]>) { + const [value, setValue] = useState(""); + return ( + {}} + onCancel={() => {}} + {...props} + /> + ); + } + + test("submits the trimmed query; ignores a blank one", async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderWithContexts(); + const input = screen.getByRole("searchbox", { name: "Search" }); + await user.type(input, " {Enter}"); + expect(onSubmit).not.toHaveBeenCalled(); + await user.type(input, " cats {Enter}"); + expect(onSubmit).toHaveBeenCalledWith("cats"); + expect(input).not.toHaveFocus(); + }); + + test("Escape cancels instead of clearing", async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + renderWithContexts(); + await user.type(screen.getByRole("searchbox"), "dogs{Escape}"); + expect(onCancel).toHaveBeenCalled(); + expect(screen.getByRole("searchbox")).toHaveValue("dogs"); + }); + + test("takes focus when it opens, not when it mounts open", () => { + const props = { + value: "", + onValueChange: () => {}, + onSubmit: () => {}, + onCancel: () => {}, + }; + const { rerender } = renderWithContexts(); + expect(screen.getByRole("searchbox")).not.toHaveFocus(); + rerender(); + expect(screen.getByRole("searchbox")).toHaveAttribute("tabindex", "-1"); + rerender(); + expect(screen.getByRole("searchbox")).toHaveFocus(); + rerender(); + expect(screen.getByRole("searchbox")).not.toHaveFocus(); + }); +}); diff --git a/src/test/components/layout/UserMenu.test.tsx b/src/test/components/layout/UserMenu.test.tsx new file mode 100644 index 00000000..19867426 --- /dev/null +++ b/src/test/components/layout/UserMenu.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { UserMenu } from "../../../components/layout/UserMenu.tsx"; +import { makeUser } from "../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../render.tsx"; + +describe("UserMenu", () => { + test("opens, links to profile and playlists, closes on Escape and outside", async () => { + const user = userEvent.setup(); + renderWithContexts( + <> + +

outside

+ , + ); + const trigger = screen.getByRole("button", { name: "User menu" }); + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute( + "href", + "/users/ann", + ); + expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute( + "href", + "/users/ann/playlists", + ); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + await user.click(trigger); + await user.click(screen.getByText("outside")); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + await user.click(trigger); + await user.click(screen.getByRole("menuitem", { name: "@ann" })); + expect(currentPath()).toBe("/users/ann"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/components/player.test.tsx b/src/test/components/player/GlobalPlayer.test.tsx similarity index 50% rename from src/test/components/player.test.tsx rename to src/test/components/player/GlobalPlayer.test.tsx index ba7aa733..e2a344e8 100644 --- a/src/test/components/player.test.tsx +++ b/src/test/components/player/GlobalPlayer.test.tsx @@ -1,19 +1,14 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { act, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useContext } from "react"; -import { GlobalPlayer } from "../../components/GlobalPlayer.tsx"; -import { JournalCard } from "../../components/JournalCard.tsx"; -import { PlaylistCard } from "../../components/PlaylistCard.tsx"; -import { PlayerProvider } from "../../contexts/PlayerProvider.tsx"; +import { GlobalPlayer } from "../../../components/player/GlobalPlayer.tsx"; +import { PlayerProvider } from "../../../contexts/PlayerProvider.tsx"; import { PlayerContext, type PlayerItem, -} from "../../contexts/PlayerContext.ts"; -import { makeDump, makePlaylist } from "../fixtures.ts"; -import { currentPath, renderWithContexts } from "../render.tsx"; -import { markPlaylistVisited } from "../../utils/visited.ts"; -import { dumpThumbnailUrl } from "../../utils/urls.ts"; +} from "../../../contexts/PlayerContext.ts"; +import { renderWithContexts } from "../../render.tsx"; let player!: React.ContextType; function Probe() { @@ -184,183 +179,10 @@ describe("GlobalPlayer", () => { renderPlayer(); act(() => player.play(track(0))); // Force a re-resolve that never lands. - const { route, deferred } = await import("../fetch.ts"); + const { route, deferred } = await import("../../fetch.ts"); const pending = deferred(); route("GET /api/bandcamp/tracks", () => pending.promise); act(() => player.onError()); expect(screen.getByText("Refreshing stream…")).toBeInTheDocument(); }); }); - -describe("JournalCard", () => { - const base = { - voteCount: 1, - voted: false, - canVote: true, - castVote: vi.fn(), - removeVote: vi.fn(), - }; - - test("image, quote and text modes", () => { - const { container, unmount } = renderWithContexts( -
    - - - -
, - ); - const cards = container.querySelectorAll("li"); - expect(cards[0]).toHaveClass( - "journal-card--image", - "journal-card--feature", - ); - expect(cards[1]).toHaveClass("journal-card--quote"); - expect(within(cards[1] as HTMLElement).getByText("A thought")) - .toBeInTheDocument(); - expect(cards[2]).toHaveClass("journal-card--text"); - expect(cards[2]).toHaveTextContent("🔗"); - unmount(); - }); - - test("a playable image card plays; an unplayable one navigates", async () => { - const play = vi.fn(); - const user = userEvent.setup(); - const yt = makeDump({ - id: "y", - richContent: { - type: "youtube", - url: "https://youtu.be/y", - thumbnailUrl: "https://i.test/y.png", - embedUrl: "https://www.youtube.com/embed/y", - }, - }); - const pic = makeDump({ - id: "p", - kind: "file", - fileMime: "image/png", - fileName: "p.png", - url: undefined, - }); - const { container } = renderWithContexts( -
    - - -
, - { player: { play } }, - ); - const cards = container.querySelectorAll("li"); - await user.click(cards[0].querySelector(".journal-card-image")!); - expect(play).toHaveBeenCalledWith( - expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }), - ); - expect(currentPath()).toBe("/"); - await user.click(cards[1].querySelector(".journal-card-image")!); - expect(currentPath()).toBe("/dumps/p"); - }); - - test("a video file shows its generated still", () => { - const dump = makeDump({ - id: "v", - kind: "file", - fileMime: "video/mp4", - url: undefined, - }); - const { container } = renderWithContexts( -
    - -
, - ); - expect(container.querySelector("img")!.getAttribute("src")).toBe( - dumpThumbnailUrl(dump), - ); - }); -}); - -describe("PlaylistCard", () => { - test("title, badge, owner, count; clicking opens it and marks it seen", async () => { - const user = userEvent.setup(); - const p = makePlaylist({ - id: "p1", - slug: "mix", - title: "Mix", - ownerUsername: "ann", - dumpCount: 1, - isPublic: false, - description: "Tunes", - }); - const { container } = renderWithContexts( -
    - -
, - ); - expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute( - "href", - "/playlists/mix", - ); - expect(screen.getByText("private")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute( - "href", - "/users/ann", - ); - expect(screen.getByText("1 dump")).toBeInTheDocument(); - expect(screen.getByText("Tunes")).toBeInTheDocument(); - await user.click(container.querySelector(".playlist-card-inner")!); - expect(currentPath()).toBe("/playlists/mix"); - }); - - test("the owner doesn't see their own name, or an unread dot", () => { - const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() }); - const { container } = renderWithContexts( -
    - -
, - ); - expect(screen.queryByText("@me")).not.toBeInTheDocument(); - expect(container.querySelector(".unread-dot")).toBeNull(); - }); - - test("a new playlist has a dot until visited", () => { - const p = makePlaylist({ id: "fresh", createdAt: new Date() }); - const first = renderWithContexts( -
    - -
, - ); - expect(first.container.querySelector(".unread-dot")).not.toBeNull(); - first.unmount(); - markPlaylistVisited("fresh"); - const again = renderWithContexts( -
    - -
, - ); - expect(again.container.querySelector(".unread-dot")).toBeNull(); - }); - - test("delete doesn't open the playlist", async () => { - const onDelete = vi.fn(); - const user = userEvent.setup(); - renderWithContexts( -
    - -
, - ); - await user.click(screen.getByRole("button", { name: "Delete playlist" })); - expect(onDelete).toHaveBeenCalled(); - expect(currentPath()).toBe("/"); - }); -}); diff --git a/src/test/components/media.test.tsx b/src/test/components/player/MediaPlayer.test.tsx similarity index 64% rename from src/test/components/media.test.tsx rename to src/test/components/player/MediaPlayer.test.tsx index dc26386a..7eb0920d 100644 --- a/src/test/components/media.test.tsx +++ b/src/test/components/player/MediaPlayer.test.tsx @@ -1,15 +1,8 @@ import { describe, expect, test, vi } from "vitest"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { MediaPlayer } from "../../components/MediaPlayer.tsx"; -import FilePreview from "../../components/FilePreview.tsx"; -import { makeDump, makeSession } from "../fixtures.ts"; -import { renderWithContexts } from "../render.tsx"; -import { - dumpFileUrl, - dumpFileVersion, - dumpThumbnailUrl, -} from "../../utils/urls.ts"; +import { MediaPlayer } from "../../../components/player/MediaPlayer.tsx"; +import { renderWithContexts } from "../../render.tsx"; function media(container: HTMLElement) { return container.querySelector("audio, video")!; @@ -211,111 +204,3 @@ describe("MediaPlayer", () => { .toBeInTheDocument(); }); }); - -describe("FilePreview", () => { - const session = makeSession(); - const file = (mime: string, over = {}) => - makeDump({ - id: "f1", - kind: "file", - fileName: "thing", - fileMime: mime, - fileSize: 2048, - url: undefined, - ...over, - }); - - test("images show inline, with the media token", () => { - const d = file("image/png"); - renderWithContexts(, { session }); - expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute( - "src", - `http://localhost:8000/api/files/f1?v=${ - dumpFileVersion(d) - }&token=${session.mediaToken}`, - ); - }); - - test("a compact image shows the server's preview, not the upload", () => { - const d = file("image/png"); - renderWithContexts(, { session }); - const img = screen.getByRole("img", { name: "thing" }); - expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken)); - expect(img).toHaveAttribute("loading", "lazy"); - }); - - test("other files are a download link", () => { - renderWithContexts(); - const link = screen.getByRole("link", { name: /Download thing/ }); - expect(link).toHaveAttribute("download", "thing"); - expect(link).toHaveTextContent("(2.0 KB)"); - }); - - test("compact audio plays in the global player", async () => { - const play = vi.fn(); - renderWithContexts(, { - player: { play }, - }); - await userEvent.click(screen.getByRole("button", { name: "Play" })); - expect(play).toHaveBeenCalledWith(expect.objectContaining({ - kind: "file", - mimeType: "audio/mpeg", - title: "Dump f1", - subtitle: "thing", - dumpHref: "/dumps/f1", - artworkUrl: undefined, - })); - }); - - test("compact video claims its generated still as artwork", async () => { - const play = vi.fn(); - const d = file("video/mp4"); - renderWithContexts(, { - player: { play }, - }); - await userEvent.click(screen.getByRole("button", { name: "Play" })); - expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d)); - expect(play.mock.calls[0][0].artworkUrl).toMatch( - /^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/, - ); - }); - - test("the global video button toggles when it's the one playing", async () => { - const togglePlay = vi.fn(); - const d = file("video/mp4"); - renderWithContexts(, { - player: { - togglePlay, - playing: true, - current: { - kind: "file", - fileUrl: dumpFileUrl(d), - mimeType: "video/mp4", - }, - }, - }); - await userEvent.click(screen.getByRole("button", { name: "Pause" })); - expect(togglePlay).toHaveBeenCalled(); - }); - - test("the global audio waveform starts playback", async () => { - const play = vi.fn(); - route("GET /api/files/:id", () => new TypeError("no audio in jsdom")); - renderWithContexts(, { - player: { play }, - }); - await userEvent.click(screen.getByRole("button", { name: "Play" })); - expect(play).toHaveBeenCalledWith( - expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }), - ); - }); - - test("compact PDFs and text get an icon", () => { - const { container } = renderWithContexts( - , - ); - expect(container).toHaveTextContent("📄"); - }); -}); - -import { route } from "../fetch.ts"; diff --git a/src/test/components/playlist/PlaylistCard.test.tsx b/src/test/components/playlist/PlaylistCard.test.tsx new file mode 100644 index 00000000..50f17246 --- /dev/null +++ b/src/test/components/playlist/PlaylistCard.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx"; +import { makePlaylist } from "../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../render.tsx"; +import { markPlaylistVisited } from "../../../utils/visited.ts"; + +describe("PlaylistCard", () => { + test("title, badge, owner, count; clicking opens it and marks it seen", async () => { + const user = userEvent.setup(); + const p = makePlaylist({ + id: "p1", + slug: "mix", + title: "Mix", + ownerUsername: "ann", + dumpCount: 1, + isPublic: false, + description: "Tunes", + }); + const { container } = renderWithContexts( +
    + +
, + ); + expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute( + "href", + "/playlists/mix", + ); + expect(screen.getByText("private")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute( + "href", + "/users/ann", + ); + expect(screen.getByText("1 dump")).toBeInTheDocument(); + expect(screen.getByText("Tunes")).toBeInTheDocument(); + await user.click(container.querySelector(".playlist-card-inner")!); + expect(currentPath()).toBe("/playlists/mix"); + }); + + test("the owner doesn't see their own name, or an unread dot", () => { + const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() }); + const { container } = renderWithContexts( +
    + +
, + ); + expect(screen.queryByText("@me")).not.toBeInTheDocument(); + expect(container.querySelector(".unread-dot")).toBeNull(); + }); + + test("a new playlist has a dot until visited", () => { + const p = makePlaylist({ id: "fresh", createdAt: new Date() }); + const first = renderWithContexts( +
    + +
, + ); + expect(first.container.querySelector(".unread-dot")).not.toBeNull(); + first.unmount(); + markPlaylistVisited("fresh"); + const again = renderWithContexts( +
    + +
, + ); + expect(again.container.querySelector(".unread-dot")).toBeNull(); + }); + + test("delete doesn't open the playlist", async () => { + const onDelete = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( +
    + +
, + ); + await user.click(screen.getByRole("button", { name: "Delete playlist" })); + expect(onDelete).toHaveBeenCalled(); + expect(currentPath()).toBe("/"); + }); +}); diff --git a/src/test/components/social/FollowButton.test.tsx b/src/test/components/social/FollowButton.test.tsx new file mode 100644 index 00000000..8f66ce70 --- /dev/null +++ b/src/test/components/social/FollowButton.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + FollowPlaylistButton, + FollowUserButton, +} from "../../../components/social/FollowButton.tsx"; +import { makeSession } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("FollowButton", () => { + const me = makeSession({ id: "me" }); + + test("hidden for guests and on yourself", () => { + const { unmount } = renderWithContexts( + , + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + unmount(); + renderWithContexts( + , + { session: me }, + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + test("follow and unfollow a user", async () => { + const followUser = vi.fn(); + const unfollowUser = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( + , + { session: me, follows: { followUser, unfollowUser } }, + ); + const btn = screen.getByRole("button", { name: "Follow ann" }); + expect(btn).toHaveTextContent("Follow"); + await user.click(btn); + expect(followUser).toHaveBeenCalledWith("u"); + expect(unfollowUser).not.toHaveBeenCalled(); + }); + + test("shows Following, and unfollows", async () => { + const unfollowUser = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( + , + { + session: me, + follows: { followedUserIds: new Set(["u"]), unfollowUser }, + }, + ); + const btn = screen.getByRole("button", { name: "Unfollow ann" }); + expect(btn).toHaveTextContent("Following"); + await user.click(btn); + expect(unfollowUser).toHaveBeenCalledWith("u"); + }); + + test("disabled until follows have loaded", () => { + renderWithContexts( + , + { + session: me, + follows: { isLoaded: false }, + }, + ); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + test("playlists: only public ones can be followed", async () => { + const followPlaylist = vi.fn(); + const unfollowPlaylist = vi.fn(); + const user = userEvent.setup(); + const priv = renderWithContexts( + , + { session: me }, + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + priv.unmount(); + renderWithContexts(, { + session: me, + follows: { + followPlaylist, + unfollowPlaylist, + followedPlaylistIds: new Set(["p"]), + }, + }); + await user.click(screen.getByRole("button", { name: "Unfollow playlist" })); + expect(unfollowPlaylist).toHaveBeenCalledWith("p"); + }); +}); diff --git a/src/test/components/social/LikeButton.test.tsx b/src/test/components/social/LikeButton.test.tsx new file mode 100644 index 00000000..7722ada3 --- /dev/null +++ b/src/test/components/social/LikeButton.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LikeButton } from "../../../components/social/LikeButton.tsx"; +import { route } from "../../fetch.ts"; +import { page } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("LikeButton", () => { + test("like and unlike, passing the count on screen; the count opens who liked", async () => { + const onLike = vi.fn(); + const onUnlike = vi.fn(); + route("GET /api/comments/:id/likers", () => page([])); + const user = userEvent.setup(); + const { rerender } = renderWithContexts( + , + ); + await user.click(screen.getByRole("button", { name: "Like" })); + expect(onLike).toHaveBeenCalledWith("c", 2); + await user.click(screen.getByText("2")); + expect(await screen.findByText("No one yet.")).toBeInTheDocument(); + rerender( + , + ); + await user.click(screen.getByRole("button", { name: "Remove like" })); + expect(onUnlike).toHaveBeenCalledWith("c", 2); + }); +}); diff --git a/src/test/components/social/UserListPopover.test.tsx b/src/test/components/social/UserListPopover.test.tsx new file mode 100644 index 00000000..1896ecba --- /dev/null +++ b/src/test/components/social/UserListPopover.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; +import { UserListPopover } from "../../../components/social/UserListPopover.tsx"; +import { calls, fail, route } from "../../fetch.ts"; +import { makeSession, makeUser, page } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("UserListPopover", () => { + function Harness({ onClose = () => {} }: { onClose?: () => void }) { + const ref = useRef(null); + return ( + <> + anchor + + + + ); + } + + test("pages through the list", async () => { + route( + "GET /api/x/voters", + (req) => + req.url.searchParams.get("page") === "1" + ? page([makeUser({ username: "a" })], true) + : page([makeUser({ username: "b" })], false), + ); + const user = userEvent.setup(); + renderWithContexts(, { session: makeSession() }); + expect(screen.getByText("Loading…")).toBeInTheDocument(); + await screen.findByText("a"); + await user.click(screen.getByRole("button", { name: "Load more" })); + await screen.findByText("b"); + expect(screen.queryByRole("button", { name: "Load more" })).not + .toBeInTheDocument(); + expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch( + /^Bearer /, + ); + }); + + test("shows the server's error, and a network one", async () => { + route("GET /api/x/voters", () => fail("Dump not found", 404), { + once: true, + }); + const first = renderWithContexts(); + await screen.findByText("Dump not found"); + first.unmount(); + route("GET /api/x/voters", () => new TypeError("offline")); + renderWithContexts(); + await screen.findByText("Could not load."); + }); + + test("a failed next page lets you try again", async () => { + route( + "GET /api/x/voters", + () => page([makeUser({ username: "a" })], true), + { once: true }, + ); + route("GET /api/x/voters", () => new TypeError("offline"), { once: true }); + route( + "GET /api/x/voters", + () => page([makeUser({ username: "b" })], false), + ); + const user = userEvent.setup(); + renderWithContexts(); + await screen.findByText("a"); + await user.click(screen.getByRole("button", { name: "Load more" })); + const retry = await screen.findByRole("button", { name: "Load more" }); + expect(retry).toBeEnabled(); + await user.click(retry); + await screen.findByText("b"); + }); + + test("closes on Escape, an outside press and a scroll — not on the anchor", async () => { + route("GET /api/x/voters", () => page([])); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithContexts(); + await screen.findByText("No one yet."); + await user.click(screen.getByText("anchor")); + await user.click(screen.getByText("No one yet.")); + expect(onClose).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + await user.click(screen.getByText("outside")); + act(() => { + globalThis.dispatchEvent(new Event("scroll")); + }); + expect(onClose).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/test/components/social/VoteButton.test.tsx b/src/test/components/social/VoteButton.test.tsx new file mode 100644 index 00000000..85acdf12 --- /dev/null +++ b/src/test/components/social/VoteButton.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { VoteButton } from "../../../components/social/VoteButton.tsx"; +import { route } from "../../fetch.ts"; +import { makeUser, page } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("VoteButton", () => { + test("upvote and remove, passing the count on screen", async () => { + const onCast = vi.fn(); + const onRemove = vi.fn(); + const user = userEvent.setup(); + const { rerender } = renderWithContexts( + , + ); + await user.click(screen.getByRole("button", { name: "Upvote" })); + expect(onCast).toHaveBeenCalledWith("d", 0); + rerender( + , + ); + await user.click(screen.getByRole("button", { name: "Remove vote" })); + expect(onRemove).toHaveBeenCalledWith("d", 3); + }); + + test("disabled for guests, with a hint", () => { + renderWithContexts( + {}} + onRemove={() => {}} + />, + ); + const btn = screen.getByRole("button", { name: "Upvote" }); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute("title", "Log in to vote"); + }); + + test("the count opens who voted, without voting", async () => { + const onCast = vi.fn(); + route( + "GET /api/dumps/:id/voters", + () => page([makeUser({ username: "fan" })]), + ); + const user = userEvent.setup(); + renderWithContexts( + {}} + />, + ); + await user.click(screen.getByText("1")); + expect(onCast).not.toHaveBeenCalled(); + expect(await screen.findByRole("menuitem", { name: /fan/ })) + .toHaveAttribute( + "href", + "/users/fan", + ); + }); +}); diff --git a/src/test/components/ui/Avatar.test.tsx b/src/test/components/ui/Avatar.test.tsx new file mode 100644 index 00000000..d12a6439 --- /dev/null +++ b/src/test/components/ui/Avatar.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import { Avatar } from "../../../components/ui/Avatar.tsx"; +import { AvatarOverrideContext } from "../../../contexts/AvatarOverrideContext.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("Avatar", () => { + test("an image when the user has one, versioned", () => { + renderWithContexts( + , + ); + expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute( + "src", + "http://localhost:8000/api/avatars/u1?v=7", + ); + }); + + test("initials otherwise, and after the image fails", () => { + renderWithContexts(); + fireEvent.error(screen.getByRole("img")); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByTitle("bob")).toHaveTextContent("B"); + }); + + test("a live override wins over stale props, and retries a failed image", () => { + const { rerender } = renderWithContexts( + + + , + ); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + rerender( + + + , + ); + // rerender replaces the tree under the same router wrapper + expect(screen.getByRole("img")).toHaveAttribute( + "src", + expect.stringContaining("?v=2"), + ); + }); +}); diff --git a/src/test/components/Modal.test.tsx b/src/test/components/ui/ConfirmModal.test.tsx similarity index 54% rename from src/test/components/Modal.test.tsx rename to src/test/components/ui/ConfirmModal.test.tsx index 6aad8d4c..2df2f573 100644 --- a/src/test/components/Modal.test.tsx +++ b/src/test/components/ui/ConfirmModal.test.tsx @@ -4,62 +4,12 @@ import userEvent from "@testing-library/user-event"; import { I18nProvider } from "@lingui/react"; import { i18n } from "@lingui/core"; import { useState } from "react"; -import { Modal } from "../../components/Modal.tsx"; -import { ConfirmModal } from "../../components/ConfirmModal.tsx"; +import { Modal } from "../../../components/ui/Modal.tsx"; +import { ConfirmModal } from "../../../components/ui/ConfirmModal.tsx"; const wrap = (ui: React.ReactNode) => render({ui}); -describe("Modal", () => { - test("is a labelled dialog that locks the page's scroll", () => { - wrap( {}}>body); - expect(screen.getByRole("dialog", { name: "Settings" })).toHaveTextContent( - "body", - ); - expect(document.documentElement.style.overflowY).toBe("hidden"); - }); - - test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => { - const onClose = vi.fn(); - const user = userEvent.setup(); - wrap( - - - , - ); - await user.click(screen.getByText("inside")); - expect(onClose).not.toHaveBeenCalled(); - await user.keyboard("{Escape}"); - await user.click(screen.getByRole("button", { name: "Close" })); - await user.click(document.querySelector(".modal-backdrop")!); - expect(onClose).toHaveBeenCalledTimes(3); - }); - - test("onBeforeClose can refuse", async () => { - const onClose = vi.fn(); - const user = userEvent.setup(); - wrap( - false}>x, - ); - await user.keyboard("{Escape}"); - await user.click(screen.getByRole("button", { name: "Close" })); - expect(onClose).not.toHaveBeenCalled(); - }); - - test("an Escape something inside already handled is left alone", async () => { - const onClose = vi.fn(); - const user = userEvent.setup(); - wrap( - - e.key === "Escape" && e.preventDefault()} /> - , - ); - await user.click(screen.getByRole("textbox")); - await user.keyboard("{Escape}"); - expect(onClose).not.toHaveBeenCalled(); - }); -}); - describe("ConfirmModal", () => { test("asks, confirms and cancels", async () => { const onConfirm = vi.fn(); diff --git a/src/test/components/ui/CountedInput.test.tsx b/src/test/components/ui/CountedInput.test.tsx new file mode 100644 index 00000000..582b091e --- /dev/null +++ b/src/test/components/ui/CountedInput.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import { CountedInput } from "../../../components/ui/CountedInput.tsx"; +import { renderWithContexts } from "../../render.tsx"; + +describe("CountedInput", () => { + test("CountedInput counts toward its limit", () => { + renderWithContexts( + {}} + />, + ); + const count = screen.getByText("90 / 100"); + expect(count).toHaveClass("text-editor-count--warn"); + expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100"); + }); +}); diff --git a/src/test/components/ui/Modal.test.tsx b/src/test/components/ui/Modal.test.tsx new file mode 100644 index 00000000..219586ee --- /dev/null +++ b/src/test/components/ui/Modal.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, test, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { Modal } from "../../../components/ui/Modal.tsx"; + +const wrap = (ui: React.ReactNode) => + render({ui}); + +describe("Modal", () => { + test("is a labelled dialog that locks the page's scroll", () => { + wrap( {}}>body); + expect(screen.getByRole("dialog", { name: "Settings" })).toHaveTextContent( + "body", + ); + expect(document.documentElement.style.overflowY).toBe("hidden"); + }); + + test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + wrap( + + + , + ); + await user.click(screen.getByText("inside")); + expect(onClose).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "Close" })); + await user.click(document.querySelector(".modal-backdrop")!); + expect(onClose).toHaveBeenCalledTimes(3); + }); + + test("onBeforeClose can refuse", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + wrap( + false}>x, + ); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "Close" })); + expect(onClose).not.toHaveBeenCalled(); + }); + + test("an Escape something inside already handled is left alone", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + wrap( + + e.key === "Escape" && e.preventDefault()} /> + , + ); + await user.click(screen.getByRole("textbox")); + await user.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/components/ui/TabBar.test.tsx b/src/test/components/ui/TabBar.test.tsx new file mode 100644 index 00000000..1f0e77b8 --- /dev/null +++ b/src/test/components/ui/TabBar.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TabBar } from "../../../components/ui/TabBar.tsx"; +import { renderWithContexts } from "../../render.tsx"; + +describe("TabBar", () => { + test("TabBar marks the active tab and reports clicks", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( + , + ); + expect(screen.getByRole("button", { name: "B" })).toHaveClass("active"); + await user.click(screen.getByRole("button", { name: "A" })); + expect(onChange).toHaveBeenCalledWith("a"); + }); +}); diff --git a/src/test/components/ui/Thumbnail.test.tsx b/src/test/components/ui/Thumbnail.test.tsx new file mode 100644 index 00000000..53179503 --- /dev/null +++ b/src/test/components/ui/Thumbnail.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import { Thumbnail } from "../../../components/ui/Thumbnail.tsx"; +import { renderWithContexts } from "../../render.tsx"; + +describe("Thumbnail", () => { + const placeholder = { url: "https://site.test/page", siteName: "Site" }; + + test("direct, then proxied, then the placeholder", () => { + renderWithContexts( + , + ); + const img = screen.getByRole("img", { name: "art" }); + expect(img).toHaveAttribute("src", "https://img.test/a.png"); + expect(img).toHaveAttribute("referrerpolicy", "no-referrer"); + fireEvent.error(img); + expect(screen.getByRole("img", { name: "art" }).getAttribute("src")) + .toContain( + "/api/proxy-image?url=", + ); + fireEvent.error(screen.getByRole("img", { name: "art" })); + expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument(); + expect(screen.getByText("S")).toBeInTheDocument(); + }); + + test("our own images skip the proxy retry", () => { + renderWithContexts( + , + ); + fireEvent.error(screen.getByRole("img", { name: "t" })); + expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); + }); + + test("no src: straight to the placeholder; a new src tries again", () => { + const { rerender } = renderWithContexts( + , + ); + expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument(); + }); +}); diff --git a/src/test/components/ui/ThumbnailPlaceholder.test.tsx b/src/test/components/ui/ThumbnailPlaceholder.test.tsx new file mode 100644 index 00000000..ff0f87ca --- /dev/null +++ b/src/test/components/ui/ThumbnailPlaceholder.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import { ThumbnailPlaceholder } from "../../../components/ui/ThumbnailPlaceholder.tsx"; +import { hueFromString } from "../../../utils/thumbnailTint.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("ThumbnailPlaceholder", () => { + test("tints by the page colour, or by hostname", () => { + const { container, rerender } = renderWithContexts( + , + ); + const box = () => + container.querySelector(".thumb-placeholder")!; + expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456"); + rerender( + , + ); + expect(box().style.getPropertyValue("--thumb-tint")).toBe( + hueFromString("site.test"), + ); + }); + + test("favicon, then the initial when it fails; a glyph wins over the initial", () => { + const { container, rerender } = renderWithContexts( + , + ); + const icon = container.querySelector("img")!; + fireEvent.error(icon); + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("S")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("🎬")).toBeInTheDocument(); + }); +}); diff --git a/src/test/components/ui/Tooltip.test.tsx b/src/test/components/ui/Tooltip.test.tsx new file mode 100644 index 00000000..fb314cfd --- /dev/null +++ b/src/test/components/ui/Tooltip.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Tooltip } from "../../../components/ui/Tooltip.tsx"; +import { renderWithContexts } from "../../render.tsx"; + +describe("Tooltip", () => { + test("Tooltip shows on hover", async () => { + const user = userEvent.setup(); + renderWithContexts( + + hover me + , + ); + await user.hover(screen.getByText("hover me")); + expect(screen.getByText("Explained")).toBeInTheDocument(); + await user.unhover(screen.getByText("hover me")); + expect(screen.queryByText("Explained")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/pages/index/FeedTabBar.test.tsx b/src/test/pages/index/FeedTabBar.test.tsx new file mode 100644 index 00000000..35fab933 --- /dev/null +++ b/src/test/pages/index/FeedTabBar.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FeedTabBar } from "../../../pages/index/FeedTabBar.tsx"; +import type { Category } from "../../../model.ts"; +import { makeCategory, makeSession } from "../../fixtures.ts"; +import { currentPath, renderWithContexts } from "../../render.tsx"; + +const categories: Category[] = [ + makeCategory({ id: "c1", slug: "music", name: "Music" }), + makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }), +]; + +describe("FeedTabBar", () => { + test("guests don't get Followed; tabs navigate within the scope", async () => { + const user = userEvent.setup(); + renderWithContexts(, { + route: "/music/hot", + path: "/:categorySlug/:feedTab", + categories, + }); + expect(screen.queryByRole("button", { name: "Followed" })).not + .toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active"); + await user.click(screen.getByRole("button", { name: "Journal" })); + expect(currentPath()).toBe("/music/journal"); + }); + + test("signed in, Followed is there", () => { + renderWithContexts(, { + session: makeSession(), + route: "/~/followed", + path: "/~/:feedTab", + }); + expect(screen.getByRole("button", { name: "Followed" })).toHaveClass( + "active", + ); + }); +}); diff --git a/src/test/pages/index/PresenceRow.test.tsx b/src/test/pages/index/PresenceRow.test.tsx new file mode 100644 index 00000000..d052a964 --- /dev/null +++ b/src/test/pages/index/PresenceRow.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, screen } from "@testing-library/react"; +import { PresenceRow } from "../../../pages/index/PresenceRow.tsx"; +import { renderWithContexts } from "../../render.tsx"; + +describe("PresenceRow", () => { + const online = (n: number) => + Array.from({ length: n }, (_, i) => ({ + userId: `u${i}`, + username: `user${i}`, + hasAvatar: false, + })); + + test("nothing when nobody's online", () => { + renderWithContexts(); + expect(document.querySelector(".index-presence")).toBeNull(); + }); + + test("up to six seats, the rest folded into a counter", () => { + renderWithContexts(, { ws: { onlineUsers: online(9) } }); + expect(screen.getAllByRole("link")).toHaveLength(5); + const more = screen.getByRole("img", { name: /4 more online/ }); + expect(more).toHaveTextContent("+4"); + expect(more).toHaveAttribute("title", "user5, user6, user7, user8"); + }); + + test("six or fewer all show", () => { + renderWithContexts(, { ws: { onlineUsers: online(6) } }); + expect(screen.getAllByRole("link")).toHaveLength(6); + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); + }); + + test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => { + const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get") + .mockReturnValue(100); + renderWithContexts( +
+
+ a +
+ +
, + { ws: { onlineUsers: online(9) } }, + ); + expect(screen.getAllByRole("link")).toHaveLength(3); + width.mockReturnValue(0); + act(() => { + globalThis.dispatchEvent(new Event("resize")); + }); + width.mockRestore(); + }); +}); diff --git a/src/utils/chatDraft.ts b/src/utils/chatDraft.ts new file mode 100644 index 00000000..43a122a2 --- /dev/null +++ b/src/utils/chatDraft.ts @@ -0,0 +1,43 @@ +import type { ChatReplyTarget } from "../contexts/WSContext.ts"; + +// The draft and reply target outlive the chat — closing it, or reloading the +// tab, keeps a half-written message. +export interface StoredDraft { + draft: string; + replyTarget: ChatReplyTarget | null; +} + +function draftKey(userId: string): string { + return `chat-draft:${userId}`; +} + +export function readDraft(userId: string | undefined): StoredDraft { + if (userId) { + try { + const raw = sessionStorage.getItem(draftKey(userId)); + const parsed = raw ? JSON.parse(raw) : null; + if (parsed && typeof parsed.draft === "string") { + return { draft: parsed.draft, replyTarget: parsed.replyTarget ?? null }; + } + } catch { + // Blocked storage or a mangled entry: start empty. + } + } + return { draft: "", replyTarget: null }; +} + +export function writeDraft( + userId: string | undefined, + value: StoredDraft, +): void { + if (!userId) return; + try { + if (value.draft || value.replyTarget) { + sessionStorage.setItem(draftKey(userId), JSON.stringify(value)); + } else { + sessionStorage.removeItem(draftKey(userId)); + } + } catch { + // The draft just won't survive a reload. + } +}