From f83381ea6934dbb0e19022f298d1eac452226e22 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 1 Oct 2025 15:21:18 -0700 Subject: [PATCH 1/4] WIP --- app/src/components/meme-selector.tsx | 2 +- app/src/components/tutorial.tsx | 124 +++++++++ app/src/controls.tsx | 360 ++++++++++++++++++++++----- app/src/hooks/useMobileLayout.tsx | 33 +++ app/src/layout/app.tsx | 140 ++++++++++- 5 files changed, 588 insertions(+), 71 deletions(-) create mode 100644 app/src/components/tutorial.tsx create mode 100644 app/src/hooks/useMobileLayout.tsx diff --git a/app/src/components/meme-selector.tsx b/app/src/components/meme-selector.tsx index bdc27fd1..63dbc666 100644 --- a/app/src/components/meme-selector.tsx +++ b/app/src/components/meme-selector.tsx @@ -163,7 +163,7 @@ export function MemeSelector(props: MemeSelectorProps): JSX.Element { return (
{/* Header with tabs */}
diff --git a/app/src/components/tutorial.tsx b/app/src/components/tutorial.tsx new file mode 100644 index 00000000..60a82f9b --- /dev/null +++ b/app/src/components/tutorial.tsx @@ -0,0 +1,124 @@ +import { createSignal, onMount, Show } from "solid-js"; +import type { JSX } from "solid-js/jsx-runtime"; + +const TUTORIAL_STORAGE_KEY = "hang-tutorial-completed"; + +export function Tutorial(): JSX.Element { + const [currentStep, setCurrentStep] = createSignal(0); + const [showTutorial, setShowTutorial] = createSignal(false); + + onMount(() => { + const completed = localStorage.getItem(TUTORIAL_STORAGE_KEY); + if (!completed) { + setShowTutorial(true); + } + }); + + const steps = [ + { + title: "Publish Media", + description: "Enable your microphone, camera, or screen sharing", + position: "bottom-left", + styles: { bottom: "5rem", left: "1rem" }, + }, + { + title: "Chat", + description: "Send messages to others in the room", + position: "bottom-center", + styles: { bottom: "5rem", left: "50%", transform: "translateX(-50%)" }, + }, + { + title: "Settings", + description: "Adjust volume, advanced settings, and fullscreen", + position: "bottom-right", + styles: { bottom: "5rem", right: "1rem" }, + }, + { + title: "Navigation", + description: "Leave room, favorite, share, and account settings", + position: "top-right", + styles: { top: "5rem", right: "1rem" }, + }, + ]; + + const nextStep = () => { + if (currentStep() < steps.length - 1) { + setCurrentStep(currentStep() + 1); + } else { + completeTutorial(); + } + }; + + const skipTutorial = () => { + completeTutorial(); + }; + + const completeTutorial = () => { + localStorage.setItem(TUTORIAL_STORAGE_KEY, "true"); + setShowTutorial(false); + }; + + return ( + + {/* Backdrop */} + +
+ +

{steps[currentStep()].description}

+ +
+
+ {steps.map((_, index) => ( +
+ ))} +
+ +
+ + +
+
+
+ + ); +} diff --git a/app/src/controls.tsx b/app/src/controls.tsx index 1c5d6da3..bbb8e052 100644 --- a/app/src/controls.tsx +++ b/app/src/controls.tsx @@ -15,6 +15,8 @@ import { import type { JSX } from "solid-js/jsx-runtime"; import { MemeSelector } from "./components/meme-selector"; import Tooltip from "./components/tooltip"; +import { Tutorial } from "./components/tutorial"; +import { useMobileLayout } from "./hooks/useMobileLayout"; import type { Room } from "./room"; import type { Canvas } from "./room/canvas"; import { Local } from "./room/local"; @@ -22,32 +24,230 @@ import { Sound } from "./room/sound"; import Settings, { Modal } from "./settings"; export function Controls(props: { room: Room; local: Local; canvas: Canvas }): JSX.Element { + const mobile = useMobileLayout(); + + // Debug: log mobile state + createEffect(() => { + console.log("isMobile:", mobile.isMobile(), "expandedSection:", mobile.expandedSection()); + }); + + // Check if any publish source is active + const micActive = solid(props.local.camera.audio.root); + const cameraActive = solid(props.local.webcam.source); + const screenActive = solid(props.local.screen.source); + const anySourceActive = createMemo(() => !!micActive() || !!cameraActive() || !!screenActive()); + + // Close expanded sections when clicking outside + let containerRef: HTMLDivElement | undefined; + + onMount(() => { + const handleClick = (e: MouseEvent) => { + // Only collapse if clicking outside the container + if ( + mobile.expandedSection() && + containerRef && + e.target instanceof Node && + !containerRef.contains(e.target) + ) { + mobile.collapseAll(); + } + }; + document.addEventListener("click", handleClick, true); // Use capture phase + onCleanup(() => document.removeEventListener("click", handleClick, true)); + }); + return ( - + <> + + + + {/* Mobile: Fixed positioned sections */} + + {/* Left: Publish section */} +
+ + + + +
+ + + +
+ + } + > + +
+
+ + {/* Center: Chat */} +
+ +
+ + {/* Right: Settings section */} +
+ +
+ + + +
+ + + + + } + > + +
+
+ + } + > + {/* Desktop: Original flex layout */} + +
+ ); } @@ -547,7 +747,7 @@ export function Visualize(props: { audio: Publish.Audio.Encoder }): JSX.Element ); } -function Chat(props: { broadcast: Publish.Broadcast; room: Room }): JSX.Element { +function Chat(props: { broadcast: Publish.Broadcast; room: Room; isMobile: boolean }): JSX.Element { const [input, setInput] = createSignal(undefined); const [message, setMessage] = createSignal(""); const [showMemeSelector, setShowMemeSelector] = createSignal(false); @@ -606,40 +806,82 @@ function Chat(props: { broadcast: Publish.Broadcast; room: Room }): JSX.Element }; return ( -
- - - - - setShowMemeSelector(false)} - /> +
+ + {/* Mobile: Chat input with emoji button on right */} +
+ setMessage(e.currentTarget.value)} + aria-label="Chat message" + tabIndex={0} + class="w-full pointer-events-auto backdrop-blur-sm bg-transparent rounded py-1 px-2 pr-10 outline-none text-center placeholder:text-center" + /> + +
+ + + setShowMemeSelector(false)} + /> + + + } + > + {/* Desktop: Original layout */} + + + + + setShowMemeSelector(false)} + /> + +
+ setMessage(e.currentTarget.value)} + aria-label="Chat message" + tabIndex={0} + class="w-full pointer-events-auto backdrop-blur-sm bg-transparent rounded py-1 px-2 outline-none text-center placeholder:text-center" + /> +
-
- setMessage(e.currentTarget.value)} - aria-label="Chat message" - tabIndex={0} - class="w-full pointer-events-auto backdrop-blur-sm bg-transparent rounded py-1 px-2 outline-none text-center placeholder:text-center" - /> -
); } diff --git a/app/src/hooks/useMobileLayout.tsx b/app/src/hooks/useMobileLayout.tsx new file mode 100644 index 00000000..1da95235 --- /dev/null +++ b/app/src/hooks/useMobileLayout.tsx @@ -0,0 +1,33 @@ +import { createSignal, onCleanup, onMount } from "solid-js"; + +export type MobileSection = "publish" | "chat" | "settings" | null; + +export function useMobileLayout() { + const [isMobile, setIsMobile] = createSignal(false); + const [expandedSection, setExpandedSection] = createSignal(null); + + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + + onMount(() => { + checkMobile(); + window.addEventListener("resize", checkMobile); + onCleanup(() => window.removeEventListener("resize", checkMobile)); + }); + + const toggleSection = (section: MobileSection) => { + setExpandedSection((prev) => (prev === section ? null : section)); + }; + + const collapseAll = () => { + setExpandedSection(null); + }; + + return { + isMobile, + expandedSection, + toggleSection, + collapseAll, + }; +} diff --git a/app/src/layout/app.tsx b/app/src/layout/app.tsx index 1ce96c6a..1d33be98 100644 --- a/app/src/layout/app.tsx +++ b/app/src/layout/app.tsx @@ -4,15 +4,91 @@ import { createResource, createSignal, JSX, Show } from "solid-js"; import * as Api from "../api"; import Login from "../components/login"; import Tooltip from "../components/tooltip"; +import { useMobileLayout } from "../hooks/useMobileLayout"; import { Logo } from "./logo"; export default function App(props: { children: JSX.Element; connection: Moq.Connection.Reload; room: string }) { return (
-
- -
-
+ ); +} + +function Header(props: { connection: Moq.Connection.Reload; room: string }) { + const mobile = useMobileLayout(); + const [showMobileNav, setShowMobileNav] = createSignal(false); + + return ( +
+ +
+ -
- - {props.children} -
+
+ + ); } @@ -50,7 +124,51 @@ function RoomNav(props: { room: string }) { + + + + + + + + + ); +} + +function RoomNavMobile(props: { room: string }) { + const [showCopiedNotification, setShowCopiedNotification] = createSignal(false); + + const share = async () => { + const url = window.location.href; + await navigator.clipboard.writeText(url); + + setShowCopiedNotification(true); + setTimeout(() => { + setShowCopiedNotification(false); + }, 3000); + }; + + return ( + <> + + @@ -64,7 +182,7 @@ function RoomNav(props: { room: string }) { - -
- - - -
- - } - > - - -
- - {/* Center: Chat */} -
- -
- {/* Right: Settings section */} -
- -
- - - -
- - - - - } - > - -
-
- - } + {/* Left: Publish section */} +
- {/* Desktop: Original flex layout */} - -

{steps[currentStep()].description}

+

{steps[step()].description}

{steps.map((_, index) => (
))} @@ -105,19 +100,40 @@ export function Tutorial(): JSX.Element {
+ + {/* Arrow */} +
); diff --git a/app/src/controls.tsx b/app/src/controls.tsx index e9362928..bbc8bcac 100644 --- a/app/src/controls.tsx +++ b/app/src/controls.tsx @@ -16,15 +16,27 @@ import type { JSX } from "solid-js/jsx-runtime"; import { MemeSelector } from "./components/meme-selector"; import Tooltip from "./components/tooltip"; import { Tutorial } from "./components/tutorial"; -import { useMobileLayout } from "./hooks/useMobileLayout"; import type { Room } from "./room"; import type { Canvas } from "./room/canvas"; import { Local } from "./room/local"; import { Sound } from "./room/sound"; import Settings, { Modal } from "./settings"; +import { isMobile } from "./util/mobile"; + +type MobileSection = "publish" | "chat" | "settings" | null; export function Controls(props: { room: Room; local: Local; canvas: Canvas }): JSX.Element { - const mobile = useMobileLayout(); + const mobile = isMobile(); + const [expandedSection, setExpandedSection] = createSignal(null); + const tutorial = solid(Settings.tutorial.step); + + const toggleSection = (section: MobileSection) => { + setExpandedSection((prev) => (prev === section ? null : section)); + }; + + const collapseAll = () => { + setExpandedSection(null); + }; // Check if any publish source is active const micActive = solid(props.local.camera.audio.root); @@ -39,17 +51,17 @@ export function Controls(props: { room: Room; local: Local; canvas: Canvas }): J onMount(() => { const handleClick = (e: MouseEvent) => { - if (!mobile.isMobile() || !mobile.expandedSection()) return; + if (!mobile() || !expandedSection()) return; const target = e.target as Node; - const expanded = mobile.expandedSection(); + const expanded = expandedSection(); // Check if click is inside the expanded section if (expanded === "publish" && publishRef?.contains(target)) return; if (expanded === "settings" && settingsRef?.contains(target)) return; // Click is outside the expanded section, so collapse - mobile.collapseAll(); + collapseAll(); }; document.addEventListener("click", handleClick, true); // Use capture phase onCleanup(() => document.removeEventListener("click", handleClick, true)); @@ -86,30 +98,29 @@ export function Controls(props: { room: Room; local: Local; canvas: Canvas }): J {/* Left: Publish section */}
- +