mirror of
https://github.com/captbaritone/webamp.git
synced 2026-11-07 17:09:05 -10:00
Replace the museum's bottom menu bar with a floating bar; drop likes
The bottom menu bar (Grid and Feed buttons, plus a menu) is gone. Every page now has a bar floating at the bottom: the museum's logo, then the grid's search box or, elsewhere, a Back button, then a "…" menu (About, Upload, Feedback, Donate, GitHub). Back goes to the previous page in the tab, animating into the grid if that's where it was, or offers "Browse skins" when there's nothing on the site to go back to. It replaces the feed's back arrow beside each skin's title. On the grid, the logo scrolls back to the start of the museum. The feed's like button is gone too. It wasn't saved anywhere: it logged skin_like and added one to the skin's Twitter like count until the page reloaded. Over Oct 14-26 it got 5.8 likes per 1,000 feed views from 2.3% of viewing sessions (downloads: 134 per 1,000, half of sessions), and among skins with similar download rates, being liked didn't predict more downloads by others. The feed no longer fetches tweets for like counts. Event schema version 8 covers both: no "menu" view switches, menu_open counts the "…" menu, "back" means the Back button, and no skin_like. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2abd828f15
commit
19a0d0103b
21 files changed
+571
-621
No files matched your search
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { ArrowLeft, Grid3x3 } from "lucide-react";
|
||||
import {
|
||||
goBack,
|
||||
previousPageInTab,
|
||||
returnToGrid,
|
||||
subscribeToPreviousPage,
|
||||
} from "./gridReturn";
|
||||
import { gridPath } from "./paths";
|
||||
import { useListPosition } from "./listPosition";
|
||||
import { isModifiedClick } from "./MuseumMenu";
|
||||
|
||||
// In the floating bar on every page but the grid, in place of its search box.
|
||||
// Back to the page you came from, if you came from one here. Otherwise (say,
|
||||
// you followed a link to this page) on into the grid.
|
||||
export default function BackButton() {
|
||||
const router = useRouter();
|
||||
const position = useListPosition();
|
||||
const previousPage = useSyncExternalStore(
|
||||
subscribeToPreviousPage,
|
||||
previousPageInTab,
|
||||
// The server can't know, and a page it renders is usually where the
|
||||
// visitor arrived.
|
||||
() => null
|
||||
);
|
||||
|
||||
if (previousPage != null) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="floating-bar-back"
|
||||
onClick={() => goBack(router)}
|
||||
>
|
||||
<ArrowLeft size={18} aria-hidden />
|
||||
Back
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={gridPath(position?.query ?? "")}
|
||||
className="floating-bar-back"
|
||||
onClick={(e) => {
|
||||
if (isModifiedClick(e)) return;
|
||||
e.preventDefault();
|
||||
returnToGrid(router);
|
||||
}}
|
||||
>
|
||||
<Grid3x3 size={18} aria-hidden />
|
||||
Browse skins
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { returnToGrid } from "./gridReturn";
|
||||
import { gridPath } from "./paths";
|
||||
import { useListPosition } from "./listPosition";
|
||||
import { isModifiedClick } from "./BottomMenuBar";
|
||||
|
||||
// Shown to the left of each skin's title in the feed.
|
||||
export default function BackToGrid() {
|
||||
const router = useRouter();
|
||||
const position = useListPosition();
|
||||
return (
|
||||
<Link
|
||||
href={gridPath(position?.query ?? "")}
|
||||
className="skin-back"
|
||||
aria-label="Back to grid"
|
||||
title="Back to grid"
|
||||
onClick={(e) => {
|
||||
if (isModifiedClick(e)) return;
|
||||
e.preventDefault();
|
||||
returnToGrid(router);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={22} aria-hidden />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Smartphone,
|
||||
Info,
|
||||
Grid3x3,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Upload,
|
||||
Github,
|
||||
Heart,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { setNextViewVia, useEventLogger } from "./eventLogger";
|
||||
import { notePathname, returnToGrid } from "./gridReturn";
|
||||
import { gridPath, skinPath } from "./paths";
|
||||
import { useListPosition } from "./listPosition";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
|
||||
import {
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
unstable_ViewTransition as ViewTransition,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
// Where "Donate" goes: thank-you donations. Following it is
|
||||
// logged as a link_click with this href, like the menu's other links.
|
||||
const DONATION_URL = "https://ko-fi.com/jordaneldredge";
|
||||
|
||||
export default function BottomMenuBar() {
|
||||
const [isHamburgerOpen, setIsHamburgerOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Publish our height so overlays (like the expanded readme) can sit right on
|
||||
// top of the bar.
|
||||
useEffect(() => {
|
||||
const bar = barRef.current;
|
||||
if (bar == null) return;
|
||||
const root = document.documentElement;
|
||||
const publish = () =>
|
||||
root.style.setProperty(
|
||||
"--bottom-nav-height",
|
||||
`${bar.getBoundingClientRect().height}px`
|
||||
);
|
||||
publish();
|
||||
const observer = new ResizeObserver(publish);
|
||||
observer.observe(bar);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
root.style.removeProperty("--bottom-nav-height");
|
||||
};
|
||||
}, []);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
notePathname(pathname);
|
||||
}, [pathname]);
|
||||
// The grid and the feed open on the skin you were last on in either.
|
||||
const position = useListPosition();
|
||||
const onFeed = pathname.startsWith("/skin/");
|
||||
|
||||
const logger = useEventLogger();
|
||||
const toggleHamburger = () => {
|
||||
if (!isHamburgerOpen) logger.log({ type: "menu_open" });
|
||||
setIsHamburgerOpen(!isHamburgerOpen);
|
||||
};
|
||||
|
||||
// Close hamburger menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Element;
|
||||
// Let the toggle button handle its own clicks. Otherwise this closes the
|
||||
// menu on mousedown and the button's click immediately reopens it.
|
||||
if (target.closest("[data-hamburger-toggle]")) {
|
||||
return;
|
||||
}
|
||||
if (menuRef.current && !menuRef.current.contains(target)) {
|
||||
setIsHamburgerOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isHamburgerOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isHamburgerOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hamburger Menu Overlay */}
|
||||
{isHamburgerOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "4.5rem",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "100%",
|
||||
maxWidth: MOBILE_MAX_WIDTH,
|
||||
backgroundColor: "rgba(26, 26, 26, 0.98)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
borderBottom: "none",
|
||||
boxShadow: "0 -4px 12px rgba(0, 0, 0, 0.3)",
|
||||
zIndex: 999,
|
||||
}}
|
||||
>
|
||||
<div ref={menuRef}>
|
||||
<HamburgerMenuItem
|
||||
href="/about"
|
||||
icon={<Info size={20} />}
|
||||
label="About"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href="/feedback"
|
||||
icon={<MessageSquare size={20} />}
|
||||
label="Feedback"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href={DONATION_URL}
|
||||
icon={<Heart size={20} />}
|
||||
label="Donate"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
external
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href="https://github.com/captbaritone/webamp/"
|
||||
icon={<Github size={20} />}
|
||||
label="GitHub"
|
||||
onClick={() => {
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Menu Bar */}
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
// Fixed, not sticky: sticky leaves it right after the content, which
|
||||
// while a page is loading is at the top of the screen. Pages leave
|
||||
// room for it via --bottom-nav-height.
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: "rgba(26, 26, 26, 0.95)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
padding: "0.75rem 0",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: MOBILE_MAX_WIDTH, // Match the scroll page max width
|
||||
display: "flex",
|
||||
justifyContent: "space-evenly",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<MenuButton
|
||||
href={gridPath(position?.query ?? "")}
|
||||
icon={<Grid3x3 size={24} />}
|
||||
label="Grid"
|
||||
isActive={pathname === "/"}
|
||||
onClick={(e) => {
|
||||
if (!onFeed) {
|
||||
setNextViewVia("menu");
|
||||
} else if (!isModifiedClick(e)) {
|
||||
// As the feed's back arrow does.
|
||||
e.preventDefault();
|
||||
returnToGrid(router, "menu");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MenuButton
|
||||
href={
|
||||
position == null
|
||||
? "/feed"
|
||||
: skinPath(position.skin.md5, position.skin.fileName)
|
||||
}
|
||||
icon={<Smartphone size={24} />}
|
||||
label="Feed"
|
||||
isActive={onFeed}
|
||||
onClick={() => {
|
||||
if (!onFeed) setNextViewVia("menu");
|
||||
}}
|
||||
/>
|
||||
<MenuButton
|
||||
href="/upload"
|
||||
icon={<Upload size={24} />}
|
||||
label="Upload"
|
||||
isActive={pathname === "/upload"}
|
||||
/>
|
||||
<MenuButton
|
||||
icon={<Menu size={24} />}
|
||||
label="Menu"
|
||||
onClick={toggleHamburger}
|
||||
isButton
|
||||
isActive={false}
|
||||
isExpanded={isHamburgerOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuButtonProps = {
|
||||
href?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
isButton?: boolean;
|
||||
isActive?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
// A click meant for the browser, e.g. to open the link in a new tab.
|
||||
export function isModifiedClick(e: React.MouseEvent): boolean {
|
||||
return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||
}
|
||||
|
||||
function MenuButton({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
isButton = false,
|
||||
isActive = false,
|
||||
isExpanded,
|
||||
onClick,
|
||||
}: MenuButtonProps) {
|
||||
const touchTargetSize = "3.0rem";
|
||||
|
||||
const containerStyle = {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.25rem",
|
||||
color: "#ccc",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "color 0.2s ease",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
position: "relative" as const,
|
||||
width: touchTargetSize,
|
||||
minWidth: touchTargetSize,
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.currentTarget.style.color = "#fff";
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Active indicator line */}
|
||||
{isActive && (
|
||||
<ViewTransition name="footer-menu-active">
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-0.75rem",
|
||||
left: 0,
|
||||
width: touchTargetSize,
|
||||
height: "1px",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
</ViewTransition>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
></span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isButton) {
|
||||
return (
|
||||
<button
|
||||
data-hamburger-toggle
|
||||
aria-label={label}
|
||||
aria-expanded={isExpanded}
|
||||
title={label}
|
||||
style={containerStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href!}
|
||||
title={label}
|
||||
style={containerStyle}
|
||||
onClick={onClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type HamburgerMenuItemProps = {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
function HamburgerMenuItem({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
external = false,
|
||||
}: HamburgerMenuItemProps) {
|
||||
const content = (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
padding: "1rem 1.5rem",
|
||||
color: "#ccc",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s ease, color 0.2s ease",
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.05)";
|
||||
e.currentTarget.style.color = "#fff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.color = "#ccc";
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.9rem",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} onClick={onClick} style={{ textDecoration: "none" }}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ type ScrollHintPillProps = {
|
||||
onTap: () => void;
|
||||
};
|
||||
|
||||
/** On mobile, a "Swipe up" hint above the bottom menu bar (see HintPill). */
|
||||
/** On mobile, a "Swipe up" hint above the floating bar (see HintPill). */
|
||||
export function ScrollHintPill({
|
||||
hasScrolled,
|
||||
onShown,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../legacy-client/src/constants";
|
||||
import MuseumMenu, { isModifiedClick } from "./MuseumMenu";
|
||||
|
||||
type Props = {
|
||||
// In place of going to the grid, e.g. on the grid itself.
|
||||
onLogoClick?: () => void;
|
||||
// Between the logo and the "…" menu: the grid's search box, or elsewhere a
|
||||
// way back (see BackButton.tsx).
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// The bar floating at the bottom of every page: the museum's logo, then
|
||||
// `children`, then the "…" menu. Pages leave room for it via
|
||||
// --floating-bar-space.
|
||||
export default function FloatingBar({ onLogoClick, children }: Props) {
|
||||
return (
|
||||
<div className="floating-bar" style={{ maxWidth: MOBILE_MAX_WIDTH }}>
|
||||
<Link
|
||||
href="/"
|
||||
className="floating-bar-logo"
|
||||
aria-label="Winamp Skin Museum"
|
||||
title="Winamp Skin Museum"
|
||||
onClick={(e) => {
|
||||
if (onLogoClick == null || isModifiedClick(e)) return;
|
||||
e.preventDefault();
|
||||
onLogoClick();
|
||||
}}
|
||||
>
|
||||
{/* The museum's logo, as in the legacy site's header. */}
|
||||
<span className="floating-bar-logo-mark" aria-hidden>
|
||||
🌩️
|
||||
</span>
|
||||
</Link>
|
||||
{children}
|
||||
<MuseumMenu />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { FixedSizeGrid as Grid } from "react-window";
|
||||
import {
|
||||
SCREENSHOT_WIDTH,
|
||||
SKIN_RATIO,
|
||||
MOBILE_MAX_WIDTH,
|
||||
} from "../../../legacy-client/src/constants";
|
||||
import { GridSkin } from "./gridSkin";
|
||||
import { MAX_GRID_IMPRESSIONS, type ListSource } from "./eventTypes";
|
||||
@@ -36,7 +35,8 @@ import { corsScreenshotUrl } from "./skinTint";
|
||||
import HintPill, { markHintDone } from "./HintPill";
|
||||
import { EyeOff, Pointer, Search, X } from "lucide-react";
|
||||
import { rememberSkinNsfw, useShowNsfw } from "./nsfwConsent";
|
||||
import { isModifiedClick } from "./BottomMenuBar";
|
||||
import { isModifiedClick } from "./MuseumMenu";
|
||||
import FloatingBar from "./FloatingBar";
|
||||
|
||||
// Remembered once the visitor has tapped a skin (see HintPill).
|
||||
const GRID_HINT_KEY = "grid-hint-dismissed";
|
||||
@@ -314,7 +314,7 @@ export default function SkinTable({
|
||||
[columnCount, skins]
|
||||
);
|
||||
|
||||
// Coming back to the grid (e.g. with the feed's back arrow) returns to the
|
||||
// Coming back to the grid (e.g. with the feed's Back button) returns to the
|
||||
// skin you were on: the one in view in the feed, if you were in the feed
|
||||
// showing this list, otherwise where this visit to the grid was scrolled
|
||||
// to. The grid is first rendered already scrolled there, so the skin you
|
||||
@@ -457,6 +457,8 @@ export default function SkinTable({
|
||||
// Keyboard shortcuts to jump to the search box: "/" (when not already typing
|
||||
// somewhere) and Cmd/Ctrl+K (from anywhere).
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
// For the logo, which scrolls back to the top.
|
||||
const gridRef = useRef<Grid>(null);
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const input = searchInputRef.current;
|
||||
@@ -521,8 +523,14 @@ export default function SkinTable({
|
||||
>
|
||||
Tap a skin to try it in Webamp
|
||||
</HintPill>
|
||||
<div className="grid-search" style={{ maxWidth: MOBILE_MAX_WIDTH }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Here, the logo goes back to the start of the museum. */}
|
||||
<FloatingBar
|
||||
onLogoClick={() => {
|
||||
setInputValue("");
|
||||
gridRef.current?.scrollTo({ scrollTop: 0 });
|
||||
}}
|
||||
>
|
||||
<div className="grid-search-field">
|
||||
<Search className="grid-search-icon" size={18} aria-hidden />
|
||||
<input
|
||||
className={
|
||||
@@ -576,7 +584,7 @@ export default function SkinTable({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FloatingBar>
|
||||
|
||||
{showSearchResults && searchError && (
|
||||
<div className="grid-message grid-message-error">{searchError}</div>
|
||||
@@ -591,6 +599,7 @@ export default function SkinTable({
|
||||
{windowSize != null &&
|
||||
(!showSearchResults || (!searchError && skins.length > 0)) && (
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
itemKey={itemKey}
|
||||
itemData={itemData}
|
||||
columnCount={columnCount}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Info,
|
||||
Ellipsis,
|
||||
MessageSquare,
|
||||
Upload,
|
||||
Github,
|
||||
Heart,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEventLogger } from "./eventLogger";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
// Where "Donate" goes: thank-you donations. Following it is
|
||||
// logged as a link_click with this href, like the menu's other links.
|
||||
const DONATION_URL = "https://ko-fi.com/jordaneldredge";
|
||||
|
||||
// The "…" menu, at the right of the floating bar (see FloatingBar.tsx).
|
||||
export default function MuseumMenu() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const logger = useEventLogger();
|
||||
const toggleMenu = () => {
|
||||
if (!isMenuOpen) logger.log({ type: "menu_open" });
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
};
|
||||
const closeMenu = () => setIsMenuOpen(false);
|
||||
|
||||
// Close the menu on a click outside it, or on Escape.
|
||||
useEffect(() => {
|
||||
if (!isMenuOpen) return;
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Element;
|
||||
// Let the toggle button handle its own clicks. Otherwise this closes the
|
||||
// menu on mousedown and the button's click immediately reopens it.
|
||||
if (target.closest("[data-menu-toggle]")) {
|
||||
return;
|
||||
}
|
||||
if (menuRef.current && !menuRef.current.contains(target)) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
setIsMenuOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
return (
|
||||
<div className="floating-menu-anchor">
|
||||
{isMenuOpen && (
|
||||
<nav
|
||||
id="floating-menu-popover"
|
||||
className="floating-menu-popover"
|
||||
ref={menuRef}
|
||||
aria-label="More"
|
||||
>
|
||||
<MenuItem
|
||||
href="/about"
|
||||
icon={<Info size={18} />}
|
||||
label="About"
|
||||
onClick={closeMenu}
|
||||
/>
|
||||
<MenuItem
|
||||
href="/upload"
|
||||
icon={<Upload size={18} />}
|
||||
label="Upload"
|
||||
onClick={closeMenu}
|
||||
/>
|
||||
<MenuItem
|
||||
href="/feedback"
|
||||
icon={<MessageSquare size={18} />}
|
||||
label="Feedback"
|
||||
onClick={closeMenu}
|
||||
/>
|
||||
<MenuItem
|
||||
href={DONATION_URL}
|
||||
icon={<Heart size={18} />}
|
||||
label="Donate"
|
||||
onClick={closeMenu}
|
||||
external
|
||||
/>
|
||||
<MenuItem
|
||||
href="https://github.com/captbaritone/webamp/"
|
||||
icon={<Github size={18} />}
|
||||
label="GitHub"
|
||||
onClick={closeMenu}
|
||||
external
|
||||
/>
|
||||
</nav>
|
||||
)}
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
className="floating-menu-button"
|
||||
data-menu-toggle
|
||||
aria-label="More"
|
||||
title="More"
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-controls="floating-menu-popover"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
<Ellipsis size={20} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A click meant for the browser, e.g. to open the link in a new tab.
|
||||
export function isModifiedClick(e: React.MouseEvent): boolean {
|
||||
return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||
}
|
||||
|
||||
type MenuItemProps = {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
function MenuItem({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
external = false,
|
||||
}: MenuItemProps) {
|
||||
const content = (
|
||||
<>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a
|
||||
className="floating-menu-item"
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className="floating-menu-item" href={href} onClick={onClick}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, ReactNode } from "react";
|
||||
import { Heart, Share2, Flag, Download, Check } from "lucide-react";
|
||||
import { Share2, Flag, Download, Check } from "lucide-react";
|
||||
import { ClientSkin } from "./gridSkin";
|
||||
import { useEventLogger } from "./eventLogger";
|
||||
import { fetchGraphql } from "./graphql";
|
||||
@@ -15,7 +15,6 @@ export default function SkinActionIcons({ skin }: Props) {
|
||||
// Laid out in scroll.css: a row under the skin on mobile, a column beside
|
||||
// it on desktop.
|
||||
<div className="skin-action-icons">
|
||||
<LikeButton skin={skin} />
|
||||
<ShareButton skin={skin} />
|
||||
<FlagButton skin={skin} />
|
||||
<DownloadButton skin={skin} />
|
||||
@@ -51,54 +50,6 @@ function Button({
|
||||
);
|
||||
}
|
||||
|
||||
// Compact counts like "2.9k" or "20.6k" keep the label short under the icon.
|
||||
// A fixed locale so the server-rendered text matches the client's.
|
||||
const LIKE_COUNT_FORMAT = new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
function formatLikeCount(count: number): string {
|
||||
// Intl uses "K"; we prefer lowercase for thousands.
|
||||
return LIKE_COUNT_FORMAT.format(count).replace(/K$/, "k");
|
||||
}
|
||||
|
||||
type LikeButtonProps = {
|
||||
skin: ClientSkin;
|
||||
};
|
||||
|
||||
function LikeButton({ skin }: LikeButtonProps) {
|
||||
const logger = useEventLogger();
|
||||
const [isLiked, setIsLiked] = useState(false);
|
||||
// Counting our like (the count can arrive after the skin, see skinList.ts).
|
||||
const likeCount = skin.likeCount + (isLiked ? 1 : 0);
|
||||
|
||||
const handleLike = async () => {
|
||||
const newLikedState = !isLiked;
|
||||
setIsLiked(newLikedState);
|
||||
|
||||
logger.log({
|
||||
type: "skin_like",
|
||||
skinMd5: skin.md5,
|
||||
liked: newLikedState,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleLike} aria-label={isLiked ? "Unlike" : "Like"}>
|
||||
<Heart
|
||||
size={32}
|
||||
color="white"
|
||||
fill={isLiked ? "white" : "none"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{likeCount > 0 && (
|
||||
<span className="skin-like-count">{formatLikeCount(likeCount)}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type ShareButtonProps = {
|
||||
skin: ClientSkin;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import { ClientSkin } from "./gridSkin";
|
||||
import SkinActionIcons from "./SkinActionIcons";
|
||||
import SkinReadme from "./SkinReadme";
|
||||
import BackToGrid from "./BackToGrid";
|
||||
import SkinScreenshot from "./SkinScreenshot";
|
||||
import { useShowNsfw } from "./nsfwConsent";
|
||||
import NsfwCover from "./NsfwCover";
|
||||
@@ -51,7 +50,6 @@ export default memo(function SkinPage({
|
||||
>
|
||||
<div className="skin-column" style={{ maxWidth: MOBILE_MAX_WIDTH }}>
|
||||
<div className="skin-title-row">
|
||||
<BackToGrid />
|
||||
<h2 className="skin-title" title={skin.fileName}>
|
||||
{skin.fileName}
|
||||
</h2>
|
||||
|
||||
@@ -21,7 +21,7 @@ type ExpandedBox = { left: number; width: number };
|
||||
/**
|
||||
* The full readme, clipped to the space under the skin. If it
|
||||
* doesn't all fit it fades out, and tapping it slides it up over the skin
|
||||
* (its bottom resting on the bottom menu bar) to reveal the rest, scrolling if
|
||||
* (its bottom resting on the floating bar) to reveal the rest, scrolling if
|
||||
* there's still more.
|
||||
*/
|
||||
export default function SkinReadme({ skin, focused }: Props) {
|
||||
|
||||
@@ -79,8 +79,7 @@ function pageInView(container: HTMLElement): HTMLElement | null {
|
||||
return nearest;
|
||||
}
|
||||
|
||||
// Load readmes and like counts for this many skins either side of the one in
|
||||
// view.
|
||||
// Load readmes for this many skins either side of the one in view.
|
||||
const DETAILS_BEHIND = 2;
|
||||
const DETAILS_AHEAD = 5;
|
||||
|
||||
@@ -566,7 +565,7 @@ export default function SkinScroller({
|
||||
setShownAfter((n) => n + SHOW_MORE);
|
||||
}, [items.length, visiblePos, lastShown, last, listLength]);
|
||||
|
||||
// Readmes and like counts for the skins around the one in view.
|
||||
// Readmes for the skins around the one in view.
|
||||
useEffect(() => {
|
||||
const md5s = items
|
||||
.slice(
|
||||
@@ -731,7 +730,8 @@ export default function SkinScroller({
|
||||
/>
|
||||
{/*
|
||||
Fades out whatever runs off the bottom of the page (usually the readme)
|
||||
into the menu bar. Fixed, so it stays put as you scroll between skins.
|
||||
above the floating bar. Fixed, so it stays put as you scroll between
|
||||
skins.
|
||||
*/}
|
||||
<div className="feed-bottom-fade" aria-hidden />
|
||||
<div className="feed-top-shadow" aria-hidden />
|
||||
|
||||
@@ -11,7 +11,8 @@ export default function StaticPage({ children }: StaticPageProps) {
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
backgroundColor: "#1a1a1a",
|
||||
paddingBottom: "5rem", // Space for bottom menu bar
|
||||
// Room to scroll the end clear of the floating bar.
|
||||
paddingBottom: "var(--floating-bar-space)",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -200,7 +200,7 @@ async function createWebamp(
|
||||
initialTracks,
|
||||
enableHotkeys: true,
|
||||
// Only needs to sit above its own screenshot. Anything higher puts it on
|
||||
// top of the bottom menu bar and its popup.
|
||||
// top of the feed's overlays (see the layering note in scroll.css).
|
||||
zIndex: 1,
|
||||
});
|
||||
// Still the default skin's: the initial skin is loading.
|
||||
|
||||
@@ -31,7 +31,15 @@
|
||||
// session's user agent (the session table).
|
||||
// - 7: adds menu_open. Before, opening the bottom bar's menu wasn't logged,
|
||||
// only following a link in it (link_click).
|
||||
export const EVENT_SCHEMA_VERSION = 7;
|
||||
// - 8: no bottom menu bar, so no Grid and Feed buttons: the feed is reached
|
||||
// only from the grid (or a link), and nothing is reached "via" "menu". Its
|
||||
// menu became a "…" button in a bar floating at the bottom of every page,
|
||||
// now with Upload in it, so menu_open counts opening that. Off the grid,
|
||||
// that bar also has a Back button (to the page before, or "Browse skins"
|
||||
// into the grid if there wasn't one; logged "via" "back"), in place of the
|
||||
// feed's back arrow beside each skin's title. And no like button, so no
|
||||
// skin_like.
|
||||
export const EVENT_SCHEMA_VERSION = 8;
|
||||
|
||||
// The most skins in one grid_impressions event (more are split across
|
||||
// several), keeping it within the endpoint's size limit.
|
||||
@@ -43,8 +51,8 @@ export type MuseumView = "grid" | "feed";
|
||||
// How the visitor got to a view (other than when the session started on it).
|
||||
export type ViewVia =
|
||||
| "grid" // tapped a skin in the grid
|
||||
| "menu" // the bottom menu bar
|
||||
| "back" // the feed's back arrow or Escape
|
||||
| "menu" // the bottom menu bar's Grid and Feed buttons (before version 8)
|
||||
| "back" // the Back button (before version 8, the feed's back arrow), or Escape in the feed
|
||||
| "history"; // the browser's back/forward buttons, or anything else
|
||||
|
||||
// How a skin came into view in the feed. Its first skin came in however the
|
||||
@@ -169,11 +177,10 @@ export type UserEvent =
|
||||
// Webamp's playback state changed (e.g. "PLAYING", "PAUSED", "STOPPED").
|
||||
// Music playing explains long dwell without implying interest in the skin.
|
||||
| { type: "audio_status"; status: string; skinMd5: string | null }
|
||||
// The feed couldn't load skins' readmes and like counts.
|
||||
// The feed couldn't load skins' readmes (before version 8, and like counts).
|
||||
| { type: "details_fetch_failure"; errorMessage: string }
|
||||
| { type: "readme_expand"; skinMd5: string }
|
||||
| { type: "skin_download"; skinMd5: string }
|
||||
| { type: "skin_like"; skinMd5: string; liked: boolean }
|
||||
| { type: "skin_flag_nsfw"; skinMd5: string }
|
||||
// Chose to see NSFW skins (from the cover over one in the feed).
|
||||
| { type: "nsfw_reveal"; skinMd5: string }
|
||||
@@ -183,7 +190,8 @@ export type UserEvent =
|
||||
| { type: "scroll_hint_shown" }
|
||||
// The grid's "Tap a skin" hint was shown.
|
||||
| { type: "grid_hint_shown" }
|
||||
// The bottom bar's menu (About, Feedback, GitHub) was opened.
|
||||
// The "…" menu (About, Upload, Feedback, Donate, GitHub) was opened. Before
|
||||
// version 8, the bottom bar's menu, without Upload.
|
||||
| { type: "menu_open" }
|
||||
// Skins in the grid that were on screen for a moment (GRID_IMPRESSION_MS)
|
||||
// once scrolling stopped, as [md5, position, source]. Each skin is reported
|
||||
@@ -219,7 +227,7 @@ export type UserEvent =
|
||||
* grid_open with session_start/view_switch; ViewContext's `position` (depth
|
||||
* into a feed session) and `source` with listIndex, query and depth;
|
||||
* skin_view_end's `exit` with endedBy; and the fetch events with
|
||||
* details_fetch_failure.
|
||||
* details_fetch_failure. Version 8 removed skin_like.
|
||||
*/
|
||||
export type LegacyEvent =
|
||||
| {
|
||||
@@ -263,7 +271,9 @@ export type LegacyEvent =
|
||||
layout: "mobile" | "desktop";
|
||||
pointer: "coarse" | "fine";
|
||||
}
|
||||
| { type: "skin_view"; skinMd5: string };
|
||||
| { type: "skin_view"; skinMd5: string }
|
||||
// The like button, before version 8. It wasn't saved anywhere.
|
||||
| { type: "skin_like"; skinMd5: string; liked: boolean };
|
||||
|
||||
// What's sent and stored for each event.
|
||||
export type LoggedEvent = UserEvent & {
|
||||
|
||||
@@ -42,16 +42,12 @@ export async function getSkinDetailsFor(
|
||||
}
|
||||
|
||||
async function getDetails(model: SkinModel): Promise<SkinDetails> {
|
||||
const [readme, tweet] = await Promise.all([
|
||||
model.getReadme(),
|
||||
model.getTweet(),
|
||||
]);
|
||||
const readme = await model.getReadme();
|
||||
const readmeText = readme?.trim() ?? "";
|
||||
return {
|
||||
readme:
|
||||
readmeText.length > README_MAX_LENGTH
|
||||
? `${readmeText.slice(0, README_MAX_LENGTH)}…`
|
||||
: readmeText,
|
||||
likeCount: tweet ? tweet.getLikes() : 0,
|
||||
};
|
||||
}
|
||||
@@ -11,19 +11,79 @@ import { gridPath } from "./paths";
|
||||
import { getListPosition } from "./listPosition";
|
||||
import { showScreenshotInsteadOfWebamp } from "./Webamp";
|
||||
|
||||
// The page before this one in the tab, and this one, as "grid", "feed" or a
|
||||
// pathname. The feed counts as one page, though its URL changes as you scroll.
|
||||
// A page, as "grid", "feed" or a pathname. The feed counts as one page,
|
||||
// though its URL changes as you scroll.
|
||||
function pageOf(pathname: string): string {
|
||||
if (pathname === "/") return "grid";
|
||||
if (pathname.startsWith("/skin/")) return "feed";
|
||||
return pathname;
|
||||
}
|
||||
|
||||
// The page before this one in the tab, and this one, as far as we've seen.
|
||||
let currentPage: string | null = null;
|
||||
let previousPage: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Note the page the tab is on (see BottomMenuBar, which is on every page). */
|
||||
/** Note the page the tab is on (see layout.tsx, which is on every page). */
|
||||
export function notePathname(pathname: string): void {
|
||||
let page = pathname;
|
||||
if (pathname === "/") page = "grid";
|
||||
if (pathname.startsWith("/skin/")) page = "feed";
|
||||
const page = pageOf(pathname);
|
||||
if (page === currentPage) return;
|
||||
previousPage = currentPage;
|
||||
currentPage = page;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
// The bits of the Navigation API used to see where Back goes. (TypeScript's
|
||||
// DOM types don't have it yet.) Its entries are this tab's history on this
|
||||
// site.
|
||||
type NavigationApi = EventTarget & {
|
||||
currentEntry: { index: number } | null;
|
||||
entries(): { url: string | null }[];
|
||||
};
|
||||
|
||||
function navigationApi(): NavigationApi | null {
|
||||
return (window as { navigation?: NavigationApi }).navigation ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Back goes in this tab: the page of the previous history entry, if
|
||||
* it's on this site. Null if Back would leave the site or there's nothing to
|
||||
* go back to, as when a link to this page opened it.
|
||||
*/
|
||||
export function previousPageInTab(): string | null {
|
||||
const navigation = navigationApi();
|
||||
if (navigation?.currentEntry != null) {
|
||||
const { url } =
|
||||
navigation.entries()[navigation.currentEntry.index - 1] ?? {};
|
||||
return url == null ? null : pageOf(new URL(url).pathname);
|
||||
}
|
||||
// Without the Navigation API, the page we last saw before this one. After
|
||||
// going Back that's the page we came back from, so it's only a guess.
|
||||
return previousPage;
|
||||
}
|
||||
|
||||
/** Calls `listener` when previousPageInTab() may have changed. */
|
||||
export function subscribeToPreviousPage(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
const navigation = navigationApi();
|
||||
// Next updates history from a useInsertionEffect, where updating React state
|
||||
// isn't allowed, and the event fires right away, so wait until after.
|
||||
const onEntryChange = () => queueMicrotask(listener);
|
||||
navigation?.addEventListener("currententrychange", onEntryChange);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
navigation?.removeEventListener("currententrychange", onEntryChange);
|
||||
};
|
||||
}
|
||||
|
||||
/** Back to the previous page in the tab, animating into the grid if it's that. */
|
||||
export function goBack(router: { push(href: string): void }): void {
|
||||
if (previousPageInTab() === "grid") {
|
||||
returnToGrid(router);
|
||||
return;
|
||||
}
|
||||
setNextViewVia("back");
|
||||
history.back();
|
||||
}
|
||||
|
||||
export function returnToGrid(
|
||||
@@ -32,7 +92,7 @@ export function returnToGrid(
|
||||
): void {
|
||||
setNextViewVia(via);
|
||||
showScreenshotInsteadOfWebamp();
|
||||
if (previousPage === "grid") {
|
||||
if (previousPageInTab() === "grid") {
|
||||
backWithTransition();
|
||||
} else {
|
||||
router.push(gridPath(getListPosition()?.query ?? ""));
|
||||
|
||||
@@ -38,7 +38,6 @@ export function toGridSkin(
|
||||
// skins around the one in view.
|
||||
export type SkinDetails = {
|
||||
readme: string;
|
||||
likeCount: number;
|
||||
};
|
||||
|
||||
// A skin as the feed shows it. Until its details arrive, `readme` is null.
|
||||
@@ -47,7 +46,6 @@ export type ClientSkin = GridSkin & {
|
||||
downloadUrl: string;
|
||||
shareUrl: string;
|
||||
readme: string | null;
|
||||
likeCount: number;
|
||||
};
|
||||
|
||||
export function toClientSkin(
|
||||
@@ -61,6 +59,5 @@ export function toClientSkin(
|
||||
downloadUrl: skinUrl,
|
||||
shareUrl: `https://skins.webamp.org${skinPath(skin.md5, skin.fileName)}`,
|
||||
readme: details?.readme ?? null,
|
||||
likeCount: details?.likeCount ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
// @ts-expect-error - unstable_ViewTransition is not yet in @types/react
|
||||
import { unstable_ViewTransition as ViewTransition, ReactNode } from "react";
|
||||
import BottomMenuBar from "./BottomMenuBar";
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { notePathname } from "./gridReturn";
|
||||
import FloatingBar from "./FloatingBar";
|
||||
import BackButton from "./BackButton";
|
||||
import "./scroll.css";
|
||||
|
||||
type LayoutProps = {
|
||||
@@ -10,6 +12,10 @@ type LayoutProps = {
|
||||
};
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
notePathname(pathname);
|
||||
}, [pathname]);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -18,9 +24,12 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ViewTransition>
|
||||
<BottomMenuBar />
|
||||
</ViewTransition>
|
||||
{/* The grid has its own, with its search box in the middle. */}
|
||||
{pathname !== "/" && (
|
||||
<FloatingBar>
|
||||
<BackButton />
|
||||
</FloatingBar>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import type { GridSkin } from "./gridSkin";
|
||||
// but a link to a skin opened fresh has no list (see followingList).
|
||||
//
|
||||
// Kept apart from skinList.ts, which loads skins with server actions, so the
|
||||
// menu bar and back links (on every page) don't bring those along.
|
||||
// back links don't bring those along.
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -25,7 +25,7 @@ function subscribe(listener: () => void): () => void {
|
||||
/**
|
||||
* The skin the visitor was last on in a list: the one in view in the feed, or
|
||||
* the first one on screen in the grid. The feed opens into this list when
|
||||
* it's opened on this skin (from the grid, the menu bar, or Forward), and the
|
||||
* it's opened on this skin (from the grid or Forward), and the
|
||||
* grid comes back to it.
|
||||
*/
|
||||
export type ListPosition = { query: string; index: number; skin: GridSkin };
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
:root {
|
||||
/* The feed's darkest color: the edges of each page's spotlight. */
|
||||
--feed-edge: #0c0c10;
|
||||
/*
|
||||
* Room the floating bar (see FloatingBar.tsx) takes at the bottom of the
|
||||
* screen: its buttons, with 1rem below them and 1rem above.
|
||||
*/
|
||||
--floating-bar-space: 4.75rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -27,12 +32,12 @@ button {
|
||||
/*
|
||||
* Layering, bottom to top: Webamp (1, set in Webamp.tsx), the top shadow
|
||||
* (500), the expanded readme (600/601), the hint pill and nav buttons (700),
|
||||
* the bottom menu bar's popup (999) and the bar itself (1000).
|
||||
* the "Done" button (800) and the floating bar (998).
|
||||
*/
|
||||
|
||||
/*
|
||||
* One feed page: a full-viewport snap point. Space at the bottom for the
|
||||
* bottom menu bar, exactly its height so the readme ends right on top of it.
|
||||
* floating bar, so the readme ends right above it.
|
||||
* Also used by the skin route's loading state so it lays out identically.
|
||||
*/
|
||||
.skin-page {
|
||||
@@ -42,7 +47,7 @@ button {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: var(--bottom-nav-height, 4rem);
|
||||
padding-bottom: var(--floating-bar-space);
|
||||
box-sizing: border-box;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
@@ -52,7 +57,8 @@ button {
|
||||
* Behind the feed: a spotlight tinted with the visible skin's colors (set by
|
||||
* SkinScroller), which stays put and morphs to each new skin's tint. The two
|
||||
* colors are registered properties so the gradient can transition.
|
||||
* The fixed fade above the menu bar blends into its dark edge (--feed-edge).
|
||||
* The fixed fade above the floating bar blends into its dark edge
|
||||
* (--feed-edge).
|
||||
*/
|
||||
@property --feed-glow {
|
||||
syntax: "<color>";
|
||||
@@ -106,12 +112,7 @@ button {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* The back-to-grid link and the skin's filename, above the skin. */
|
||||
/*
|
||||
* The title is centered on the column, like a navigation bar; the arrow sits at
|
||||
* the left edge, with matching room on both sides so long titles don't run
|
||||
* into it.
|
||||
*/
|
||||
/* The skin's filename, centered above the skin. */
|
||||
.skin-title-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -119,33 +120,11 @@ button {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.skin-back {
|
||||
position: absolute;
|
||||
/* Pull the arrow's built-in whitespace back to the column edge. */
|
||||
left: -0.3rem;
|
||||
display: flex;
|
||||
color: #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skin-back:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skin-back:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.skin-back:focus-visible {
|
||||
outline: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* The skin's filename. One line so every page lays out the same. */
|
||||
.skin-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 1.75rem;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
@@ -237,12 +216,12 @@ button {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Back to scrolling. Above the menu bar, over the faded readme. */
|
||||
/* Back to scrolling. Above the floating bar, over the faded readme. */
|
||||
.webamp-done {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--bottom-nav-height, 4rem) + 16px);
|
||||
bottom: calc(var(--floating-bar-space) + 0.5rem);
|
||||
z-index: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -274,7 +253,7 @@ button {
|
||||
.skin-action-icons {
|
||||
display: flex;
|
||||
/*
|
||||
* Reversed so the like button (first in the column on desktop) sits at the
|
||||
* Reversed so the share button (first in the column on desktop) sits at the
|
||||
* right edge. With row-reverse, flex-start is the right-hand side.
|
||||
*/
|
||||
flex-direction: row-reverse;
|
||||
@@ -306,12 +285,6 @@ button {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.skin-like-count {
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/*
|
||||
* "Copied" confirmation on the share button. Floats over its surroundings so
|
||||
* it doesn't shift the other icons. Above the icon in the mobile row.
|
||||
@@ -336,15 +309,153 @@ button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The grid's search box, floating above the bottom menu bar. */
|
||||
.grid-search {
|
||||
/*
|
||||
* The bar floating at the bottom of every page (see FloatingBar.tsx): the
|
||||
* logo, the grid's search box or a back button, and the "…" menu.
|
||||
*/
|
||||
.floating-bar {
|
||||
position: fixed;
|
||||
bottom: 4.25rem;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(100% - 2rem);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
z-index: 998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.grid-search-field {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.floating-menu-anchor {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* The round buttons at either end of the bar: the logo and the "…" menu.
|
||||
* Styled to match the search box.
|
||||
*/
|
||||
.floating-bar-logo,
|
||||
.floating-menu-button {
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 50%;
|
||||
background-color: rgba(20, 20, 24, 0.85);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.floating-bar-logo:hover,
|
||||
.floating-menu-button:hover,
|
||||
.floating-menu-button[aria-expanded="true"] {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.floating-bar-logo {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.floating-bar-logo-mark {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Between the logo and the "…" menu everywhere but the grid. */
|
||||
.floating-bar-back {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(20, 20, 24, 0.85);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.95rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.floating-bar-back:hover {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.floating-bar-logo:focus-visible,
|
||||
.floating-bar-back:focus-visible,
|
||||
.floating-menu-button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Opens upward from the button, aligned to its right edge. */
|
||||
.floating-menu-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
min-width: 11rem;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.875rem;
|
||||
background-color: rgba(20, 20, 24, 0.95);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.6);
|
||||
animation: floating-menu-in 0.12s ease-out;
|
||||
}
|
||||
|
||||
@keyframes floating-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.floating-menu-item:hover,
|
||||
.floating-menu-item:focus-visible {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -362,12 +473,9 @@ button {
|
||||
color: #fff;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-shadow:
|
||||
0 6px 20px rgba(0, 0, 0, 0.6),
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transition:
|
||||
padding-right 0.2s ease,
|
||||
border-color 0.15s ease,
|
||||
transition: padding-right 0.2s ease, border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -382,9 +490,7 @@ button {
|
||||
.grid-search-input:focus {
|
||||
background-color: rgba(20, 20, 24, 0.92);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
box-shadow:
|
||||
0 6px 20px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 3px rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6), 0 0 0 3px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.grid-search-icon {
|
||||
@@ -575,11 +681,6 @@ button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fades the bottom of each page (a collapsed readme that doesn't fit) into the
|
||||
* menu bar. Fixed to the viewport rather than part of each page, so it doesn't
|
||||
* scroll along with them. Below the expanded readme, which has its own.
|
||||
*/
|
||||
/* Shades the top edge of the feed, under the title row. */
|
||||
.feed-top-shadow {
|
||||
position: fixed;
|
||||
@@ -597,29 +698,36 @@ button {
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fades the bottom of each page (a collapsed readme that doesn't fit) out
|
||||
* just above the floating bar, then stays solid to the bottom of the screen,
|
||||
* so the bar sits on the feed's dark edge rather than the spotlight. Fixed to
|
||||
* the viewport rather than part of each page, so it doesn't scroll along with
|
||||
* them. Below the expanded readme, which has its own.
|
||||
*/
|
||||
.feed-bottom-fade {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--bottom-nav-height, 4rem);
|
||||
height: 2.5rem;
|
||||
background: linear-gradient(to bottom, transparent, var(--feed-edge));
|
||||
bottom: 0;
|
||||
height: calc(var(--floating-bar-space) + 2.5rem);
|
||||
background: linear-gradient(to bottom, transparent, var(--feed-edge) 2.5rem);
|
||||
pointer-events: none;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
/*
|
||||
* Expanded: the same element, grown upward over the skin. It keeps its bottom
|
||||
* edge, which is the top of the bottom menu bar. Positioned against the (full
|
||||
* viewport height) feed page.
|
||||
* edge, just above the floating bar. Positioned against the (full viewport
|
||||
* height) feed page.
|
||||
*/
|
||||
.skin-readme.expanded {
|
||||
position: absolute;
|
||||
bottom: var(--bottom-nav-height, 4rem);
|
||||
bottom: var(--floating-bar-space);
|
||||
z-index: 601;
|
||||
flex: none;
|
||||
/* Leave the top shadow area clear. */
|
||||
max-height: calc(100% - var(--bottom-nav-height, 4rem) - 3rem);
|
||||
max-height: calc(100% - var(--floating-bar-space) - 3rem);
|
||||
/* Solid, since it covers the skin. */
|
||||
background: #15151a;
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
@@ -701,14 +809,10 @@ button {
|
||||
animation: hint-pill-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* The feed's "Swipe up" hint, just above the bottom menu bar. */
|
||||
.hint-pill-feed {
|
||||
bottom: calc(var(--bottom-nav-height, 4rem) + 0.75rem);
|
||||
}
|
||||
|
||||
/* The grid's "Tap a skin" hint, just above its floating search bar. */
|
||||
/* The hints, just above the floating bar. */
|
||||
.hint-pill-feed,
|
||||
.hint-pill-grid {
|
||||
bottom: 8.25rem;
|
||||
bottom: calc(var(--floating-bar-space) + 0.25rem);
|
||||
}
|
||||
|
||||
.hint-pill-icon {
|
||||
@@ -833,7 +937,8 @@ button {
|
||||
}
|
||||
|
||||
.hint-pill,
|
||||
.hint-pill-icon {
|
||||
.hint-pill-icon,
|
||||
.floating-menu-popover {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { unstable_ViewTransition as ViewTransition } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { MOBILE_MAX_WIDTH } from "../../../../../legacy-client/src/constants";
|
||||
import BackToGrid from "../../BackToGrid";
|
||||
import SkinScreenshot from "../../SkinScreenshot";
|
||||
import { getKnownNsfw, useShowNsfw } from "../../nsfwConsent";
|
||||
|
||||
@@ -26,7 +25,6 @@ export default function Loading() {
|
||||
<div className="skin-column" style={{ maxWidth: MOBILE_MAX_WIDTH }}>
|
||||
{/* Same row as SkinPage, so the skin doesn't move when it loads. */}
|
||||
<div className="skin-title-row">
|
||||
<BackToGrid />
|
||||
<div className="skin-title" aria-hidden>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user