v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:30:28 +00:00
parent de5abeceb2
commit 07d0344be7
131 changed files with 3893 additions and 3596 deletions

View File

@@ -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 =

View File

@@ -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,

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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";

View File

@@ -9,7 +9,7 @@ import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "../components/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 {

View File

@@ -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,

View File

@@ -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";

View File

@@ -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();

View File

@@ -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,

View File

@@ -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 && (
<NewPlaylistForm
<NewPlaylistButton
toggleClassName="btn-primary"
onCreated={(p) =>
setState((s) => {

View File

@@ -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() {
</Trans>
</h2>
{isOwnProfile && (
<NewPlaylistForm
<NewPlaylistButton
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;

View File

@@ -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,

View File

@@ -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();

View File

@@ -0,0 +1,52 @@
import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/useAuth.ts";
import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
} from "../../config/feedTabs.ts";
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
import { TabBar } from "../../components/ui/TabBar.tsx";
import { CategorySwitcher } from "../../components/category/CategorySwitcher.tsx";
/**
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the
* current scope: `~` for all categories, or the active category slug — so the
* tabs link to `/~/new`, `/music/new`, etc.
*/
export function FeedTabBar() {
const { user } = useAuth();
const navigate = useNavigate();
const { categorySlug, feedTab } = useParams();
const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user
? DEFAULT_FEED_TAB
: preferredTab;
const active: FeedTab =
(FEED_TABS as readonly string[]).includes(feedTab ?? "") &&
(feedTab !== "followed" || !!user)
? (feedTab as FeedTab)
: defaultTab;
const scope = categorySlug ?? "~";
const tabs = [
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
{ key: "new" as FeedTab, label: <Trans>New</Trans> },
{ key: "journal" as FeedTab, label: <Trans>Journal</Trans> },
...(user
? [{ key: "followed" as FeedTab, label: <Trans>Followed</Trans> }]
: []),
];
return (
<TabBar
tabs={tabs}
activeTab={active}
onChange={(t) => navigate(`/${scope}/${t}`)}
leading={<CategorySwitcher />}
/>
);
}

View File

@@ -1,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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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(

View File

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