mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-23 02:15:01 +00:00
Stub out menu bar
This commit is contained in:
parent
608242b200
commit
7afe3bd45b
6 changed files with 444 additions and 35 deletions
340
packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx
Normal file
340
packages/skin-database/app/(modern)/scroll/BottomMenuBar.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Smartphone,
|
||||
Search,
|
||||
Info,
|
||||
Grid3x3,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Upload,
|
||||
Github,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { logUserEvent } from "./Events";
|
||||
|
||||
type Props = {
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export default function BottomMenuBar({ sessionId }: Props) {
|
||||
const [isHamburgerOpen, setIsHamburgerOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
const handleMenuClick = (menuItem: string) => {
|
||||
if (sessionId) {
|
||||
logUserEvent(sessionId, {
|
||||
type: "menu_click",
|
||||
menuItem,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleHamburger = () => {
|
||||
setIsHamburgerOpen(!isHamburgerOpen);
|
||||
handleMenuClick("hamburger");
|
||||
};
|
||||
|
||||
// Close hamburger menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
isHamburgerOpen
|
||||
) {
|
||||
setIsHamburgerOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isHamburgerOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isHamburgerOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hamburger Menu Overlay */}
|
||||
{isHamburgerOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "4.5rem",
|
||||
left: 0,
|
||||
width: "100%",
|
||||
backgroundColor: "rgba(26, 26, 26, 0.98)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
zIndex: 999,
|
||||
}}
|
||||
>
|
||||
<HamburgerMenuItem
|
||||
href="/about"
|
||||
icon={<Info size={20} />}
|
||||
label="About"
|
||||
onClick={() => {
|
||||
handleMenuClick("about");
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href="/upload"
|
||||
icon={<Upload size={20} />}
|
||||
label="Upload"
|
||||
onClick={() => {
|
||||
handleMenuClick("upload");
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
/>{" "}
|
||||
<HamburgerMenuItem
|
||||
href="https://github.com/captbaritone/webamp/issues"
|
||||
icon={<MessageSquare size={20} />}
|
||||
label="Feedback"
|
||||
onClick={() => {
|
||||
handleMenuClick("feedback");
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
external
|
||||
/>
|
||||
<HamburgerMenuItem
|
||||
href="https://github.com/captbaritone/webamp/"
|
||||
icon={<Github size={20} />}
|
||||
label="GitHub"
|
||||
onClick={() => {
|
||||
handleMenuClick("feedback");
|
||||
setIsHamburgerOpen(false);
|
||||
}}
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Menu Bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
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: "space-evenly",
|
||||
alignItems: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<MenuButton
|
||||
href="/scroll"
|
||||
icon={<Smartphone size={24} />}
|
||||
label="Feed"
|
||||
onClick={() => handleMenuClick("feed")}
|
||||
isActive={pathname === "/scroll"}
|
||||
/>
|
||||
<MenuButton
|
||||
href="/"
|
||||
icon={<Grid3x3 size={24} />}
|
||||
label="Grid"
|
||||
onClick={() => handleMenuClick("grid")}
|
||||
isActive={pathname === "/"}
|
||||
/>
|
||||
<MenuButton
|
||||
href="/"
|
||||
icon={<Search size={24} />}
|
||||
label="Search"
|
||||
onClick={() => handleMenuClick("search")}
|
||||
isActive={false}
|
||||
/>
|
||||
<MenuButton
|
||||
icon={<Menu size={24} />}
|
||||
label="Menu"
|
||||
onClick={toggleHamburger}
|
||||
isButton
|
||||
isActive={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuButtonProps = {
|
||||
href?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
isButton?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
function MenuButton({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
isButton = false,
|
||||
isActive = false,
|
||||
}: 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 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-0.75rem",
|
||||
left: 0,
|
||||
width: touchTargetSize,
|
||||
height: "1px",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isButton) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={containerStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href!}
|
||||
onClick={onClick}
|
||||
style={containerStyle}
|
||||
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} style={{ textDecoration: "none" }}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} onClick={onClick} style={{ textDecoration: "none" }}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -93,4 +93,8 @@ export type UserEvent =
|
|||
type: "share_failure";
|
||||
skinMd5: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
| {
|
||||
type: "menu_click";
|
||||
menuItem: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,20 +19,22 @@ export default function SkinPage({ skin, index, sessionId }: Props) {
|
|||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100vh",
|
||||
scrollSnapAlign: "start",
|
||||
scrollSnapStop: "always",
|
||||
position: "relative",
|
||||
paddingTop: "2rem", // Space for top shadow
|
||||
paddingBottom: "5rem", // Space for bottom menu bar
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<div style={{ position: "relative", flexShrink: 0 }}>
|
||||
<img
|
||||
src={skin.screenshotUrl}
|
||||
alt={skin.fileName}
|
||||
style={{
|
||||
paddingTop: "4rem",
|
||||
boxSizing: "border-box",
|
||||
width: "100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
|
|
@ -44,9 +46,9 @@ export default function SkinPage({ skin, index, sessionId }: Props) {
|
|||
<div
|
||||
style={{
|
||||
color: "white",
|
||||
flexGrow: 1,
|
||||
paddingLeft: "0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
|
|
|
|||
|
|
@ -134,31 +134,45 @@ export default function SkinScroller({
|
|||
}, [visibleSkinIndex, skins, fetching]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
style={{
|
||||
height: "100vh",
|
||||
width: "100vw",
|
||||
maxWidth: "56.25vh", // 9:16 aspect ratio (100vh * 9/16)
|
||||
margin: "0 auto",
|
||||
overflowY: "scroll",
|
||||
scrollSnapType: "y mandatory",
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE and Edge
|
||||
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
|
||||
}}
|
||||
className="hide-scrollbar"
|
||||
>
|
||||
{skins.map((skin, i) => {
|
||||
return (
|
||||
<SkinPage
|
||||
key={skin.md5}
|
||||
skin={skin}
|
||||
index={i}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
style={{
|
||||
height: "100vh",
|
||||
width: "100%",
|
||||
overflowY: "scroll",
|
||||
scrollSnapType: "y mandatory",
|
||||
scrollbarWidth: "none", // Firefox
|
||||
msOverflowStyle: "none", // IE and Edge
|
||||
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
|
||||
}}
|
||||
className="hide-scrollbar"
|
||||
>
|
||||
{skins.map((skin, i) => {
|
||||
return (
|
||||
<SkinPage
|
||||
key={skin.md5}
|
||||
skin={skin}
|
||||
index={i}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Top shadow overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "4rem",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(26, 26, 26, 0.8) 0%, rgba(26, 26, 26, 0.4) 50%, rgba(26, 26, 26, 0) 100%)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 500,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
46
packages/skin-database/app/(modern)/scroll/layout.tsx
Normal file
46
packages/skin-database/app/(modern)/scroll/layout.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode, createContext, useContext } from "react";
|
||||
import BottomMenuBar from "./BottomMenuBar";
|
||||
|
||||
type SessionContextType = {
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
const SessionContext = createContext<SessionContextType>({ sessionId: null });
|
||||
|
||||
export function useSession() {
|
||||
return useContext(SessionContext);
|
||||
}
|
||||
|
||||
type LayoutWrapperProps = {
|
||||
children: ReactNode;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export function LayoutWrapper({ children, sessionId }: LayoutWrapperProps) {
|
||||
return (
|
||||
<SessionContext.Provider value={{ sessionId }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100vw",
|
||||
maxWidth: "56.25vh", // 9:16 aspect ratio (100vh * 9/16)
|
||||
margin: "0 auto",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<BottomMenuBar sessionId={sessionId} />
|
||||
</div>
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function ScrollLayout({ children }: Props) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import "./scroll.css";
|
|||
import SkinScroller, { ClientSkin } from "./SkinScroller";
|
||||
import { getScrollPage } from "../../../data/skins";
|
||||
import SkinModel from "../../../data/SkinModel";
|
||||
import { LayoutWrapper } from "./layout";
|
||||
|
||||
// Ensure each page load gets a new session
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -51,10 +52,12 @@ export default async function ScrollPage() {
|
|||
const initialSkins = await getClientSkins(sessionId);
|
||||
|
||||
return (
|
||||
<SkinScroller
|
||||
initialSkins={initialSkins}
|
||||
getSkins={getClientSkins}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
<LayoutWrapper sessionId={sessionId}>
|
||||
<SkinScroller
|
||||
initialSkins={initialSkins}
|
||||
getSkins={getClientSkins}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</LayoutWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue