v3: search engine, responsive header with compact user menu

This commit is contained in:
khannurien
2026-03-29 11:56:31 +00:00
parent f0f6472db6
commit cbb3505139
31 changed files with 1206 additions and 178 deletions

View File

@@ -1573,10 +1573,10 @@ body.has-player .fab-new {
transform: translateY(-1px);
}
@media (min-width: 740px) {
@media (min-width: 860px) {
.app-header--has-center {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-columns: auto 1fr auto;
}
}
@@ -1593,14 +1593,38 @@ body.has-player .fab-new {
display: none;
align-items: center;
justify-content: center;
min-width: 0;
overflow: hidden;
container-type: inline-size;
}
@media (min-width: 740px) {
@media (min-width: 860px) {
.app-header-center {
display: flex;
}
}
/* When the search bar is expanded, immediately clear the rest of the center —
the expanded input needs the full column width */
.app-header-center:has(.search-bar--expanded) .index-presence,
.app-header-center:has(.search-bar--expanded) .feed-sort {
display: none;
}
/* As the center column shrinks (viewport narrow, search collapsed),
shed content in order: presence first, then tabs (still in .index-below-header) */
@container (max-width: 460px) {
.index-presence {
display: none;
}
}
@container (max-width: 280px) {
.feed-sort {
display: none;
}
}
.header-center-slot {
display: flex;
align-items: center;
@@ -1617,7 +1641,33 @@ body.has-player .fab-new {
justify-content: flex-end;
gap: 0.6rem;
margin-left: auto;
flex-wrap: wrap;
flex-shrink: 0;
flex-wrap: nowrap;
}
/* Text links — visible only at wide viewports */
.nav-links {
display: none;
align-items: center;
gap: 0.6rem;
}
@media (min-width: 1150px) {
.nav-links {
display: flex;
}
}
/* Avatar menu — visible only below the text-links breakpoint */
.nav-compact {
display: flex;
align-items: center;
}
@media (min-width: 1150px) {
.nav-compact {
display: none;
}
}
.app-header-user {
@@ -1637,6 +1687,57 @@ body.has-player .fab-new {
background: var(--color-header-user-bg-hover);
}
/* ── User menu (compact nav) ── */
.user-menu {
position: relative;
}
.user-menu-trigger {
display: flex;
align-items: center;
background: transparent;
border: none;
border-radius: 8px;
padding: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.user-menu-trigger:hover {
background: var(--color-header-user-bg-hover);
}
.user-menu-dropdown {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
min-width: 150px;
background: var(--color-surface);
border: 2px solid var(--color-border-subtle);
border-radius: 10px;
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
z-index: 100;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.user-menu-item {
display: block;
padding: 0.45rem 0.75rem;
border-radius: 7px;
text-decoration: none;
color: var(--color-text);
font-size: 0.9rem;
font-weight: 600;
transition: background 0.12s;
}
.user-menu-item:hover {
background: var(--color-header-user-bg-hover);
}
/* ── Auth card ── */
.auth-card {
width: 100%;
@@ -1780,6 +1881,7 @@ body.has-player .fab-new {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
cursor: pointer;
border-radius: 8px;
font-family: inherit;
@@ -1866,7 +1968,7 @@ body.has-player .fab-new {
gap: 0;
}
@media (min-width: 740px) {
@media (min-width: 860px) {
.index-page .dump-feed {
margin-top: 1rem;
}
@@ -1913,7 +2015,7 @@ body.has-player .fab-new {
padding: 0.6rem 1.25rem 0;
}
@media (min-width: 740px) {
@media (min-width: 860px) {
.index-below-header {
display: none;
}
@@ -3597,3 +3699,144 @@ body.has-player .fab-new {
z-index: 9999;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
/* ── Search bar (in header center slot) ── */
.search-bar {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
max-width: 380px;
}
.search-bar--collapsible {
width: auto;
max-width: none;
}
.search-bar--collapsible.search-bar--expanded {
width: 100%;
max-width: 320px;
}
.search-bar-input {
flex: 1;
padding: 0.3rem 0.7rem;
border-radius: 8px;
border: 2px solid var(--color-border-subtle);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
outline: none;
transition: max-width 0.25s ease, opacity 0.2s ease, padding 0.25s ease,
border-width 0.25s ease, border-color 0.15s;
min-width: 0;
}
/* Collapsed state: hide the input while keeping it in the DOM */
.search-bar--collapsible .search-bar-input {
max-width: 0;
opacity: 0;
padding-left: 0;
padding-right: 0;
border-width: 0;
pointer-events: none;
}
/* Expanded state: animate it open */
.search-bar--collapsible.search-bar--expanded .search-bar-input {
max-width: 280px;
opacity: 1;
padding-left: 0.7rem;
padding-right: 0.7rem;
border-width: 2px;
pointer-events: auto;
}
.search-bar-input:focus {
border-color: var(--color-accent);
}
.search-bar-btn {
padding: 0.3rem 0.55rem;
background: transparent;
border: 2px solid var(--color-border-subtle);
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
flex-shrink: 0;
transition: border-color 0.15s;
}
.search-bar-btn:hover {
border-color: var(--color-accent);
}
/* ── Search page ── */
.search-page {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 3rem;
}
.search-tabs {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 1rem 1.25rem 0;
width: 100%;
max-width: 860px;
box-sizing: border-box;
}
.search-page-empty {
padding: 3rem 1.25rem;
color: var(--color-text-secondary);
text-align: center;
font-size: 0.95rem;
}
/* ── User results ── */
.user-results {
list-style: none;
margin: 0;
padding: 0.75rem 1.25rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 860px;
width: 100%;
box-sizing: border-box;
}
.user-result-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border-radius: 10px;
border: 2px solid var(--color-border-subtle);
text-decoration: none;
color: var(--color-text);
transition: border-color 0.15s;
}
.user-result-item:hover {
border-color: var(--color-accent);
}
.user-result-name {
font-weight: 600;
font-size: 0.95rem;
}
.user-result-description {
font-size: 0.85rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -13,6 +13,7 @@ import { UserUpvoted } from "./pages/UserUpvoted.tsx";
import { UserPlaylists } from "./pages/UserPlaylists.tsx";
import { PlaylistDetail } from "./pages/PlaylistDetail.tsx";
import { Notifications } from "./pages/Notifications.tsx";
import { Search } from "./pages/Search.tsx";
import { AuthProvider } from "./contexts/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
@@ -64,6 +65,7 @@ function AppRoutes() {
element={<UserPlaylists />}
/>
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
<Route path="/search" element={<Search />} />
<Route
path="/notifications"
element={

View File

@@ -4,6 +4,7 @@ import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
import { NotificationBell } from "./NotificationBell.tsx";
import { UserMenu } from "./UserMenu.tsx";
export function AppHeader(
{ centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean },
@@ -28,18 +29,27 @@ export function AppHeader(
{user
? (
<>
<Link
to={`/users/${user.username}`}
className="app-header-user"
>
{user.username}
</Link>
<Link
to={`/users/${user.username}/playlists`}
className="app-header-user"
>
Playlists
</Link>
{/* Full text links — hidden below the compact breakpoint */}
<div className="nav-links">
<Link
to={`/users/${user.username}`}
className="app-header-user"
>
{user.username}
</Link>
<Link
to={`/users/${user.username}/playlists`}
className="app-header-user"
>
Playlists
</Link>
</div>
{/* Compact avatar menu — hidden above the compact breakpoint */}
<div className="nav-compact">
<UserMenu user={user} />
</div>
<NotificationBell />
<button
type="button"

View File

@@ -0,0 +1,53 @@
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
export type FeedTab = "hot" | "new" | "journal" | "followed";
export const VALID_TABS = new Set<string>(["hot", "new", "journal", "followed"]);
export function FeedTabBar() {
const location = useLocation();
const navigate = useNavigate();
const { user } = useAuth();
const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot";
const tab: FeedTab = VALID_TABS.has(rawTab) ? (rawTab as FeedTab) : "hot";
function setTab(t: FeedTab) {
navigate(`/?tab=${t}`, { replace: true });
}
return (
<div className="feed-sort">
<button
type="button"
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
onClick={() => setTab("hot")}
>
Hot
</button>
<button
type="button"
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
onClick={() => setTab("new")}
>
New
</button>
<button
type="button"
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
onClick={() => setTab("journal")}
>
Journal
</button>
{user && (
<button
type="button"
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
Followed
</button>
)}
</div>
);
}

View File

@@ -1,15 +1,17 @@
import { type ReactNode } from "react";
import { AppHeader } from "./AppHeader.tsx";
import { SearchBar } from "./SearchBar.tsx";
interface PageShellProps {
children: ReactNode;
centered?: boolean;
centerSlot?: ReactNode;
}
export function PageShell({ children, centered = false }: PageShellProps) {
export function PageShell({ children, centered = false, centerSlot }: PageShellProps) {
return (
<div className="page-shell">
<AppHeader />
<AppHeader centerSlot={centerSlot ?? <SearchBar />} />
<main
className={`page-content${centered ? " page-content--centered" : ""}`}
>

View File

@@ -0,0 +1,30 @@
import { Link } from "react-router";
import { Avatar } from "./Avatar.tsx";
import { useWS } from "../hooks/useWS.ts";
export function PresenceRow() {
const { onlineUsers } = useWS();
if (onlineUsers.length === 0) return null;
return (
<div className="index-presence">
{onlineUsers.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={32}
version={u.avatarVersion}
/>
</Link>
))}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
interface SearchBarProps {
collapsible?: boolean;
}
export function SearchBar({ collapsible = false }: SearchBarProps) {
const [value, setValue] = useState(
() => new URLSearchParams(location.search).get("q") ?? "",
);
const [expanded, setExpanded] = useState(!collapsible);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
useEffect(() => {
if (expanded) inputRef.current?.focus();
}, [expanded]);
function handleIconClick() {
if (!collapsible) return;
if (expanded) {
setExpanded(false);
setValue("");
} else {
setExpanded(true);
}
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
const q = value.trim();
if (!q) return;
navigate(`/search?q=${encodeURIComponent(q)}&tab=dumps`);
if (collapsible) {
setExpanded(false);
setValue("");
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape" && collapsible) {
setExpanded(false);
setValue("");
}
}
return (
<form
className={`search-bar${collapsible ? " search-bar--collapsible" : ""}${expanded ? " search-bar--expanded" : ""}`}
onSubmit={handleSubmit}
role="search"
>
<input
ref={inputRef}
type="search"
className="search-bar-input"
placeholder="Search dumps, users, playlists…"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
aria-label="Search"
tabIndex={expanded ? 0 : -1}
/>
<button
type={expanded && !collapsible ? "submit" : "button"}
className="search-bar-btn"
aria-label={expanded ? "Submit search" : "Open search"}
onClick={collapsible ? handleIconClick : undefined}
>
🔍
</button>
</form>
);
}

View File

@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { Avatar } from "./Avatar.tsx";
import type { User } from "../model.ts";
export function UserMenu({ user }: { user: User }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div className="user-menu" ref={ref}>
<button
type="button"
className="user-menu-trigger"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label="User menu"
>
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={28}
/>
</button>
{open && (
<div className="user-menu-dropdown" role="menu">
<Link
to={`/users/${user.username}`}
className="user-menu-item"
role="menuitem"
onClick={() => setOpen(false)}
>
@{user.username}
</Link>
<Link
to={`/users/${user.username}/playlists`}
className="user-menu-item"
role="menuitem"
onClick={() => setOpen(false)}
>
Playlists
</Link>
</div>
)}
</div>
);
}

View File

@@ -22,10 +22,11 @@ export const WS_URL: string = API_URL
? API_URL.replace(/^http/, "ws")
: `${location.protocol.replace("http", "ws")}//${location.host}`;
export const SEARCH_URL = `${API_URL}/api/search`;
export const DEFAULT_PAGE_SIZE = 20;
export const NOTIFICATIONS_PAGE_SIZE = 30;
// Validation constants (mirrors api/model/interfaces.ts VALIDATION)
// Validation constants (mirrors api/config.ts VALIDATION)
export const VALIDATION = {
USERNAME_MIN: 1,
USERNAME_MAX: 32,

View File

@@ -82,6 +82,7 @@
body {
margin: 0;
min-height: 100vh;
overflow-x: clip;
background-color: var(--color-bg);
color: var(--color-text);
}

View File

@@ -6,10 +6,12 @@ import {
useRef,
useState,
} from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { useLocation } from "react-router";
import { Avatar } from "../components/Avatar.tsx";
import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx";
import { FeedTabBar, type FeedTab, VALID_TABS } from "../components/FeedTabBar.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
@@ -46,19 +48,13 @@ type DumpsState =
loadingMore: boolean;
};
type FeedTab = "hot" | "new" | "journal" | "followed";
const VALID_TABS = new Set<string>(["hot", "new", "journal", "followed"]);
export function Index() {
const location = useLocation();
const navigate = useNavigate();
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId;
const { user, token } = useAuth();
const {
onlineUsers,
voteCounts,
myVotes,
recentDumps,
@@ -89,10 +85,6 @@ export function Index() {
const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot";
const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot";
function setTab(t: FeedTab) {
navigate(`/?tab=${t}`, { replace: true });
}
// ── Main feed fetch ──
useEffect(() => {
@@ -237,77 +229,22 @@ export function Index() {
// ── Render ──
const presenceRow = (
<div className="index-presence">
{onlineUsers.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={32}
version={u.avatarVersion}
/>
</Link>
))}
</div>
);
const tabBar = (
<div className="feed-sort">
<button
type="button"
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
onClick={() => setTab("hot")}
>
Hot
</button>
<button
type="button"
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
onClick={() => setTab("new")}
>
New
</button>
<button
type="button"
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
onClick={() => setTab("journal")}
>
Journal
</button>
{user && (
<button
type="button"
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
Followed
</button>
)}
</div>
);
return (
<div className="index-page">
<AppHeader
centerSlot={
<div className="header-center-slot">
{presenceRow}
{tabBar}
<PresenceRow />
<FeedTabBar />
<SearchBar collapsible />
</div>
}
disableNew={dumpsState.status === "error"}
/>
<div className="index-below-header">
{tabBar}
{presenceRow}
<FeedTabBar />
<PresenceRow />
</div>
{tab === "hot" && <HotFeed {...mainFeedProps} />}

256
src/pages/Search.tsx Normal file
View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import {
deserializeDump,
deserializePlaylist,
deserializePublicUser,
type Dump,
type Playlist,
type PublicUser,
type RawDump,
type RawPlaylist,
type RawPublicUser,
} from "../model.ts";
import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts";
type Tab = "dumps" | "users" | "playlists";
type SearchState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
q: string;
dumps: {
items: Dump[];
total: number;
hasMore: boolean;
page: number;
loadingMore: boolean;
};
users: PublicUser[];
playlists: Playlist[];
};
export function Search() {
const [searchParams, setSearchParams] = useSearchParams();
const q = searchParams.get("q") ?? "";
const tab = (searchParams.get("tab") ?? "dumps") as Tab;
const { token, user } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS();
const [state, setState] = useState<SearchState>({ status: "idle" });
const abortRef = useRef<AbortController | null>(null);
const fetchSearch = useCallback(async (query: string, page: number) => {
if (!query.trim()) {
setState({ status: "idle" });
return;
}
if (page === 1) {
setState({ status: "loading" });
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const url = new URL(SEARCH_URL);
url.searchParams.set("q", query);
url.searchParams.set("page", String(page));
url.searchParams.set("limit", String(DEFAULT_PAGE_SIZE));
const res = await fetch(url.toString(), {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Search failed (${res.status})`);
const body = await res.json() as {
success: true;
data: {
dumps: { items: RawDump[]; total: number; hasMore: boolean };
users: RawPublicUser[];
playlists: RawPlaylist[];
};
};
const { data } = body;
if (page === 1) {
setState({
status: "loaded",
q: query,
dumps: {
items: data.dumps.items.map(deserializeDump),
total: data.dumps.total,
hasMore: data.dumps.hasMore,
page: 1,
loadingMore: false,
},
users: data.users.map(deserializePublicUser),
playlists: data.playlists.map(deserializePlaylist),
});
} else {
setState((prev) => {
if (prev.status !== "loaded") return prev;
return {
...prev,
dumps: {
...prev.dumps,
items: [...prev.dumps.items, ...data.dumps.items.map(deserializeDump)],
hasMore: data.dumps.hasMore,
page,
loadingMore: false,
},
};
});
}
} catch (err) {
if ((err as Error).name === "AbortError") return;
setState({ status: "error", error: (err as Error).message });
}
}, [token]);
useEffect(() => {
fetchSearch(q, 1);
return () => abortRef.current?.abort();
}, [q, fetchSearch]);
const loadMore = useCallback(() => {
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore) return;
setState((prev) => {
if (prev.status !== "loaded") return prev;
return { ...prev, dumps: { ...prev.dumps, loadingMore: true } };
});
fetchSearch(q, state.dumps.page + 1);
}, [state, q, fetchSearch]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && tab === "dumps" && state.dumps.hasMore && !state.dumps.loadingMore,
);
function setTab(t: Tab) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", t);
return next;
}, { replace: true });
}
const dumpCount = state.status === "loaded" ? state.dumps.total : null;
const userCount = state.status === "loaded" ? state.users.length : null;
const playlistCount = state.status === "loaded" ? state.playlists.length : null;
function tabLabel(t: Tab, count: number | null) {
const label = t === "dumps" ? "Dumps" : t === "users" ? "Users" : "Playlists";
return count !== null ? `${label} (${count})` : label;
}
return (
<div className="page-shell">
<AppHeader centerSlot={<SearchBar />} />
<main className="search-page">
{q && (
<div className="search-tabs">
{(["dumps", "users", "playlists"] as Tab[]).map((t) => (
<button
key={t}
type="button"
className={`feed-sort-btn${tab === t ? " active" : ""}`}
onClick={() => setTab(t)}
>
{tabLabel(t, t === "dumps" ? dumpCount : t === "users" ? userCount : playlistCount)}
</button>
))}
</div>
)}
{state.status === "idle" && (
<p className="search-page-empty">Enter a query to search.</p>
)}
{state.status === "loading" && (
<p className="search-page-empty">Searching</p>
)}
{state.status === "error" && (
<ErrorCard title="Search failed" message={state.error} />
)}
{state.status === "loaded" && tab === "dumps" && (
<>
{state.dumps.items.length === 0
? <p className="search-page-empty">No dumps match "{q}".</p>
: (
<ul className="dump-feed">
{state.dumps.items.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{state.dumps.loadingMore && <p className="feed-loading-more">Loading more</p>}
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
<p className="feed-end">You've reached the end.</p>
)}
</>
)}
{state.status === "loaded" && tab === "users" && (
state.users.length === 0
? <p className="search-page-empty">No users match "{q}".</p>
: (
<ul className="user-results">
{state.users.map((u) => (
<li key={u.id}>
<Link to={`/users/${u.username}`} className="user-result-item">
<span className="user-result-name">@{u.username}</span>
{u.description && (
<span className="user-result-description">{u.description}</span>
)}
</Link>
</li>
))}
</ul>
)
)}
{state.status === "loaded" && tab === "playlists" && (
state.playlists.length === 0
? <p className="search-page-empty">No playlists match "{q}".</p>
: (
<ul className="dump-feed">
{state.playlists.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
)
)}
</main>
</div>
);
}