v3: the header stops cropping itself and the search stops losing its place — a review of the header, search bar and presence row turned up tabs cut off at both ends, avatars that could only be all there or gone, and a search that forgot what it was showing. Between 860px and about 1000px, and again just past 1150px where the username and Playlists links arrive, presence plus tabs ran wider than the header's centre column; the slot couldn't shrink, so the column clipped it on both sides, and logged in FOLLOWED went missing with one person online. The container queries meant to shed presence under 460px and tabs under 280px never fired, overridden by later rules of the same weight, and no fixed width would have been right anyway: the tabs run from 314px (nyt, English) to 485px (melange, French). The slot can now shrink, and the presence row sizes itself through a new useRoomBeside, which measures the room the tabs leave it in the header and in the row under it: as many avatars as fit, at most six seats, the rest folded into a "+N" counter (square in brutalist), and never fewer than an avatar and the counter — below that the tabs scroll instead. So presence shows at every width, phones included, where a 512px rule used to hide it. When the header's tabs do scroll, they get the app's right-edge fade in the header's own colour, only while they overflow, and the active tab is scrolled clear of it. The avatars fan out on hover only where there's a pointer, so a tap doesn't leave them spread, and the label-less "+" button squares up with the icons beside it on small screens. Search: the ✕ on results opened directly ran navigate(-1) out of the app and now goes home; after several searches it stepped back one query at a time and now returns to where the search began in one step, carried in the entry's state, which useTabParam now keeps across result tabs; those tabs (/search/~/users) weren't recognised as the results page at all. On results the bar stays open holding the current query, including after a new search or Back, where it used to collapse and empty. The input no longer grabs focus on a results page, so a phone's keyboard doesn't cover the results, and submitting puts it away. Escape closes an opened search and returns focus to its button, or on results reverts an edited query, overriding the search input's own clear-on-Escape. The two search bars share one draft, so crossing the breakpoint keeps it.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 10:00:15 +00:00
parent bfce3186f8
commit 5fd1d364da
13 changed files with 849 additions and 628 deletions

View File

@@ -2655,7 +2655,6 @@ body.has-player .chat-fab {
min-width: 0;
overflow-x: clip;
overflow-y: visible;
container-type: inline-size;
}
@media (min-width: 860px) {
@@ -2725,24 +2724,13 @@ body.has-player .chat-fab {
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;
gap: 0.75rem;
/* Lets the tabs scroll as a last resort, rather than the centred slot
overflowing the column and being clipped at both ends. */
min-width: 0;
}
.app-header-brand:hover {
@@ -2884,7 +2872,8 @@ body.has-player .chat-fab {
justify-content: center;
}
/* Ghost search button — always visible except when the center search bar is shown */
/* Ghost search button — opens the search (in the center column, or the row
under the header on small viewports) and turns into its ✕ */
.nav-search-btn {
display: flex;
align-items: center;
@@ -3404,17 +3393,39 @@ body.has-player .chat-fab {
margin-left: 0;
}
.index-presence:hover .index-presence-avatar {
margin-left: 4px;
/* Hover only where there's a pointer to hover with — a tap would otherwise
leave the row fanned out and the tapped avatar raised. */
@media (hover: hover) {
.index-presence:hover .index-presence-avatar {
margin-left: 4px;
}
.index-presence:hover .index-presence-avatar:first-child {
margin-left: 0;
}
.index-presence-avatar:hover {
transform: translateY(-3px) scale(1.12);
z-index: 2;
}
}
.index-presence:hover .index-presence-avatar:first-child {
margin-left: 0;
}
.index-presence-avatar:hover {
transform: translateY(-3px) scale(1.12);
z-index: 2;
/* The "+N" that ends a crowded row, sized and ringed like the avatars. */
.index-presence-more {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: var(--text-2xs);
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: default;
box-sizing: border-box;
border: var(--border-width) solid var(--color-border-subtle);
}
.index-status {
@@ -3465,8 +3476,14 @@ body.has-player .chat-fab {
pointer-events: none;
}
/* In the app header the tabs never overflow — suppress the gradient there */
/* In the app header the tabs only scroll as a last resort, and a permanent
fade would dim the last tab whenever they fit — so it shows only while they
overflow, in the header's own colour. */
.app-header .feed-sort-scroller::after {
background: linear-gradient(to right, transparent, var(--header-bg));
}
.app-header .feed-sort-scroller:not(.feed-sort-scroller--overflowing)::after {
display: none;
}
@@ -3992,12 +4009,6 @@ body.has-player .chat-fab {
align-self: center;
}
@media (max-width: 512px) {
.index-below-header .index-presence {
display: none;
}
}
@media (max-width: 512px) {
.app-header-brand-name {
display: none;
@@ -4006,6 +4017,12 @@ body.has-player .chat-fab {
.btn-new-label {
display: none;
}
/* Down to its "+", the button squares up with the icons beside it. */
.app-header-nav .btn-primary {
width: 2.25rem;
padding: 0;
}
}
/* ── Playlist card image thumbnail ── */

View File

@@ -1,5 +1,5 @@
import { lazy, type ReactNode, Suspense, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
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";
@@ -14,6 +14,19 @@ const DumpCreateModal = lazy(() =>
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
);
/** Carried in a search entry's history state: the history index of the page
* the search started from, so leaving search returns there in one step
* however many searches (or result tabs) came in between. */
interface SearchEntryState {
searchExit?: number;
}
/** React Router's index of the current history entry — 0 for the first page
* of the session, which has nothing in the app to go back to. */
function historyIndex(): number {
return (window.history.state as { idx?: number } | null)?.idx ?? 0;
}
export function AppHeader(
{ centerSlot, disableNew, initialDumpUrl }: {
centerSlot?: ReactNode;
@@ -25,22 +38,72 @@ export function AppHeader(
const { wsStatus, wsErrorMessage } = useWS();
const navigate = useNavigate();
const location = useLocation();
const isSearchPage = location.pathname === "/search";
const [searchExpanded, setSearchExpanded] = useState(isSearchPage);
const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl);
const [searchParams] = useSearchParams();
// The results page and each of its tabs (/search/~/users…).
const isSearchPage = location.pathname === "/search" ||
location.pathname.startsWith("/search/");
const query = isSearchPage ? searchParams.get("q") ?? "" : "";
function handleSearchToggle() {
if (searchExpanded) {
if (isSearchPage) {
navigate(-1);
} else {
setSearchExpanded(false);
}
// On the results page the search stays open, holding the query it shows.
const [searchOpen, setSearchOpen] = useState(false);
const searchExpanded = isSearchPage || searchOpen;
const [draft, setDraft] = useState(query);
const [prevQuery, setPrevQuery] = useState(query);
if (prevQuery !== query) {
setPrevQuery(query);
setDraft(query);
}
const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl);
const searchToggleRef = useRef<HTMLButtonElement>(null);
function submitSearch(q: string) {
const here = location.state as SearchEntryState | null;
// A search from elsewhere remembers that page; one from the results page
// passes on what it was given, or — for results reached by a link —
// the page before them. Results that opened the session have nowhere to
// return to, so a new search just replaces them.
const searchExit = !isSearchPage
? historyIndex()
: here?.searchExit ?? (historyIndex() > 0 ? historyIndex() - 1 : null);
const to = `/search?q=${encodeURIComponent(q)}`;
if (searchExit === null) navigate(to, { replace: true });
else navigate(to, { state: { searchExit } satisfies SearchEntryState });
}
function closeSearch() {
if (isSearchPage) {
const exit = (location.state as SearchEntryState | null)?.searchExit;
if (exit !== undefined) navigate(exit - historyIndex());
else if (historyIndex() > 0) navigate(-1);
else navigate("/");
} else {
setSearchExpanded(true);
setSearchOpen(false);
setDraft("");
searchToggleRef.current?.focus();
}
}
// Escape leaves an opened search, but only reverts an edited query on the
// results page — leaving that page is the ✕'s job.
function escapeSearch() {
if (isSearchPage) setDraft(query);
else closeSearch();
}
function handleSearchToggle() {
if (searchExpanded) closeSearch();
else setSearchOpen(true);
}
const searchBarProps = {
expanded: searchExpanded,
value: draft,
onValueChange: setDraft,
onSubmit: submitSearch,
onCancel: escapeSearch,
};
return (
<>
<header className="app-header app-header--has-center">
@@ -53,17 +116,16 @@ export function AppHeader(
<div className="app-header-center">
{centerSlot}
<SearchBar
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
<SearchBar {...searchBarProps} />
</div>
<nav className="app-header-nav">
<button
ref={searchToggleRef}
type="button"
className="nav-search-btn"
aria-label={searchExpanded ? t`Cancel search` : t`Search`}
aria-expanded={searchExpanded}
onClick={handleSearchToggle}
>
{searchExpanded ? "✕" : "🔍"}
@@ -127,10 +189,7 @@ export function AppHeader(
>
<div className="header-search-reveal-clip">
<div className="header-search-below">
<SearchBar
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
<SearchBar {...searchBarProps} />
</div>
</div>
</div>

View File

@@ -1,15 +1,49 @@
import { useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Avatar } from "./Avatar.tsx";
import { useWS } from "../hooks/useWS.ts";
import { useRoomBeside } from "../hooks/useRoomBeside.ts";
export function PresenceRow() {
// 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 } = useWS();
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">
{onlineUsers.map((u) => (
<div className="index-presence" ref={setEl}>
{shown.map((u) => (
<Link
key={u.userId}
to={`/users/${u.username}`}
@@ -20,11 +54,21 @@ export function PresenceRow() {
userId={u.userId}
username={u.username}
hasAvatar={u.hasAvatar}
size={32}
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>
);
}

View File

@@ -1,81 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { useEffect, useRef } from "react";
import { t } from "@lingui/core/macro";
interface SearchBarProps {
collapsible?: boolean;
expanded?: boolean;
onExpandedChange?: (v: boolean) => void;
expanded: boolean;
value: string;
onValueChange: (v: string) => void;
onSubmit: (q: string) => void;
/** Escape pressed in the input. */
onCancel: () => void;
}
/**
* The header's search form. The header renders two of these (one in its
* centre column, one in the row that slides open under it on small
* viewports) over one shared state, so the draft survives crossing the
* breakpoint.
*/
export function SearchBar(
{ collapsible = false, expanded: expandedProp, onExpandedChange }:
SearchBarProps,
{ expanded, value, onValueChange, onSubmit, onCancel }: SearchBarProps,
) {
const isControlled = expandedProp !== undefined;
const [expandedState, setExpandedState] = useState(!collapsible);
const expanded = isControlled ? expandedProp! : expandedState;
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const [value, setValue] = useState(
() => new URLSearchParams(location.search).get("q") ?? "",
);
// Collapsing always discards the draft, however it was collapsed (the ✕ in
// the header toggles `expanded` from outside).
const [prevExpanded, setPrevExpanded] = useState(expanded);
if (prevExpanded !== expanded) {
setPrevExpanded(expanded);
if (!expanded) setValue("");
}
function setExpanded(v: boolean) {
if (!isControlled) setExpandedState(v);
onExpandedChange?.(v);
}
const wasExpanded = useRef(expanded);
useEffect(() => {
if ((collapsible || isControlled) && expanded) {
// No scroll-into-view: the below-header row is still clipped while it
// slides open, and scrolling the clip would offset its content.
// Only an opening takes focus: a page that mounts with search already
// open (the results page) mustn't raise a phone's keyboard over its
// results. No scroll-into-view either: the below-header row is still
// clipped while it slides open, and scrolling the clip would offset its
// content. Of the two bars, the hidden one just ignores the call.
if (expanded && !wasExpanded.current) {
inputRef.current?.focus({ preventScroll: true });
} else if (!expanded && inputRef.current === document.activeElement) {
inputRef.current?.blur();
}
}, [expanded, collapsible, isControlled]);
function handleIconClick() {
if (!collapsible && !isControlled) return;
if (expanded) {
setExpanded(false);
setValue("");
} else {
setExpanded(true);
}
}
wasExpanded.current = expanded;
}, [expanded]);
function handleSubmit(e: React.SubmitEvent) {
e.preventDefault();
const q = value.trim();
if (!q) return;
navigate(`/search?q=${encodeURIComponent(q)}`);
if (collapsible || isControlled) {
setExpanded(false);
setValue("");
}
// Submitting shows results — put a phone's keyboard away so they can be
// seen.
inputRef.current?.blur();
onSubmit(q);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape" && (collapsible || isControlled)) {
setExpanded(false);
setValue("");
if (e.key === "Escape") {
// A search input clears itself on Escape, which would undo a revert.
e.preventDefault();
onCancel();
}
}
return (
<form
className={`search-bar${
collapsible || isControlled ? " search-bar--collapsible" : ""
}${expanded ? " search-bar--expanded" : ""}`}
className={`search-bar search-bar--collapsible${
expanded ? " search-bar--expanded" : ""
}`}
onSubmit={handleSubmit}
role="search"
>
@@ -85,18 +68,16 @@ export function SearchBar(
className="search-bar-input"
placeholder={t`Search dumps, users, playlists…`}
value={value}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={handleKeyDown}
aria-label={t`Search`}
tabIndex={expanded ? 0 : -1}
/>
<button
type={expanded ? "submit" : "button"}
type="submit"
className="search-bar-btn"
aria-label={expanded ? t`Submit search` : t`Open search`}
onClick={!expanded && (collapsible || isControlled)
? handleIconClick
: undefined}
aria-label={t`Submit search`}
tabIndex={expanded ? 0 : -1}
>
🔍
</button>

View File

@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
interface Tab<T extends string> {
key: T;
@@ -20,9 +20,48 @@ export function TabBar<T extends string>(
{ tabs, activeTab, onChange, className, innerClassName, leading }:
TabBarProps<T>,
) {
const rowRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState(false);
// Flags a row whose tabs don't all fit, for places that fade the edge only
// then (the app header) rather than always.
useLayoutEffect(() => {
const row = rowRef.current;
if (!row) return;
const measure = () => setOverflowing(row.scrollWidth > row.clientWidth);
const resize = new ResizeObserver(measure);
resize.observe(row);
for (const child of row.children) resize.observe(child);
measure();
return () => resize.disconnect();
}, [tabs.length]);
// When they don't all fit, keep the active tab in view, clear of the fade
// over the row's right edge — scrolling the row alone, never the page.
useLayoutEffect(() => {
const row = rowRef.current;
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
if (!row || !active || !overflowing) return;
// The fade is 2rem wide (.feed-sort-scroller::after).
const fade =
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
if (b.left < r.left) row.scrollLeft -= r.left - b.left;
else if (b.right > r.right - fade) {
row.scrollLeft += b.right - r.right + fade;
}
}, [activeTab, overflowing]);
return (
<div className={`feed-sort-scroller${className ? ` ${className}` : ""}`}>
<div className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}>
<div
className={`feed-sort-scroller${
overflowing ? " feed-sort-scroller--overflowing" : ""
}${className ? ` ${className}` : ""}`}
>
<div
ref={rowRef}
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
>
{leading}
{tabs.map(({ key, label }) => (
<button

View File

@@ -0,0 +1,73 @@
import { useLayoutEffect, useState } from "react";
/**
* How much width an element has to itself in a row it shares with something
* that takes precedence — in the feed's header, how much the tabs leave the
* presence avatars. `within` (an ancestor selector) is what bounds the row,
* `beside` (found inside it) is what comes first, measured at its content's
* natural width because how wide the tabs run depends on the theme's type and
* on the locale. The row's gap between the two is taken off too.
*
* Takes the element itself (from a callback ref) rather than a ref object,
* so the measuring starts whenever it mounts. Its own width doesn't enter
* into it, so it can size itself to the answer without feeding back into the
* next one. Null until measured.
*/
export function useRoomBeside(
el: HTMLElement | null,
{ within, beside }: { within?: string; beside?: string },
): number | null {
const [room, setRoom] = useState<number | null>(null);
useLayoutEffect(() => {
const container = within ? el?.closest<HTMLElement>(within) : null;
if (!el || !container || !beside) return;
const row = el.parentElement;
function measure() {
const kept = container!.querySelector<HTMLElement>(beside!);
const cs = getComputedStyle(container!);
const width = container!.clientWidth - parseFloat(cs.paddingLeft) -
parseFloat(cs.paddingRight);
const gap = row ? parseFloat(getComputedStyle(row).columnGap) || 0 : 0;
setRoom(Math.floor(width - gap - (kept ? contentWidth(kept) : 0)));
}
// The container for its width; the tabs themselves, since they change
// width without their box changing (a web font arriving, a tab added at
// login) — re-bound when they come and go.
const resize = new ResizeObserver(measure);
const mutation = new MutationObserver(observe);
function observe() {
resize.disconnect();
mutation.disconnect();
resize.observe(container!);
const kept = container!.querySelector<HTMLElement>(beside!);
if (kept) {
for (const child of kept.children) resize.observe(child);
mutation.observe(kept, { childList: true });
}
measure();
}
observe();
return () => {
resize.disconnect();
mutation.disconnect();
};
}, [el, within, beside]);
return room;
}
/** How wide an element's content runs, from its first child to its last plus
* its padding — neither its box, which a flex row may squeeze (the tabs then
* scroll) or stretch (filling the row under the header), nor its
* scrollWidth, which is at least the box. */
function contentWidth(el: HTMLElement): number {
const first = el.firstElementChild, last = el.lastElementChild;
if (!first || !last) return 0;
const cs = getComputedStyle(el);
return last.getBoundingClientRect().right -
first.getBoundingClientRect().left + parseFloat(cs.paddingLeft) +
parseFloat(cs.paddingRight);
}

View File

@@ -37,10 +37,15 @@ export function useTabParam<T extends string>(
const setTab = useCallback(
(t: T) => {
const target = t === defaultTab && base ? base : `${base}${SEP}${t}`;
// Preserve any unrelated query string (e.g. the search page's `?q=`).
navigate(`${target}${location.search}`, { replace: true });
// Preserve any unrelated query string (e.g. the search page's `?q=`),
// and the entry's state along with it — this is the same entry, and the
// header's search reads where to go back to from there.
navigate(`${target}${location.search}`, {
replace: true,
state: location.state,
});
},
[navigate, base, defaultTab, location.search],
[navigate, base, defaultTab, location.search, location.state],
);
return [tab, setTab];

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -319,7 +319,7 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
<AppHeader
centerSlot={
<div className="header-center-slot">
<PresenceRow />
<PresenceRow fitWithin=".app-header-center" fitBeside=".feed-sort" />
<FeedTabBar />
</div>
}
@@ -329,7 +329,7 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
<div className="index-below-header">
<FeedTabBar />
<PresenceRow />
<PresenceRow fitWithin=".index-below-header" fitBeside=".feed-sort" />
</div>
{tab === "hot" && <HotFeed {...mainFeedProps} />}

View File

@@ -60,6 +60,7 @@
[data-style="brutalist"] .avatar-img,
[data-style="brutalist"] .avatar-initials,
[data-style="brutalist"] .avatar-change-overlay,
[data-style="brutalist"] .index-presence-more,
[data-style="brutalist"] .notif-icon,
[data-style="brutalist"] .notif-dot {
border-radius: 0;