v3: search engine, responsive header with compact user menu
This commit is contained in:
@@ -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"
|
||||
|
||||
53
src/components/FeedTabBar.tsx
Normal file
53
src/components/FeedTabBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" : ""}`}
|
||||
>
|
||||
|
||||
30
src/components/PresenceRow.tsx
Normal file
30
src/components/PresenceRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/SearchBar.tsx
Normal file
75
src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/UserMenu.tsx
Normal file
64
src/components/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user