From f05f7be8b00ddd727164c807d575c03652928c3a Mon Sep 17 00:00:00 2001 From: assistant Date: Tue, 31 Mar 2026 03:01:41 +0000 Subject: [PATCH 1/2] feat(stages): E2E stage creation with 3D builder, map picker, music upload, and visualization selector --- .../musician/stages/create/page.tsx | 7 +- .../(musician)/musician/stages/map/page.tsx | 75 ++++++ .../(musician)/musician/stages/new/page.tsx | 219 +----------------- src/app/(musician)/musician/stages/page.tsx | 19 +- src/app/api/stages/route.ts | 6 +- src/components/stages/MapPicker.tsx | 93 +++++++- 6 files changed, 180 insertions(+), 239 deletions(-) create mode 100644 src/app/(musician)/musician/stages/map/page.tsx diff --git a/src/app/(musician)/musician/stages/create/page.tsx b/src/app/(musician)/musician/stages/create/page.tsx index 48a7e0d..04f348e 100644 --- a/src/app/(musician)/musician/stages/create/page.tsx +++ b/src/app/(musician)/musician/stages/create/page.tsx @@ -211,12 +211,7 @@ export default function CreateStagePage() { } toast.success(publish ? "Stage published!" : "Stage created!"); - - if (publish) { - router.push("/explore"); - } else { - router.push(`/musician/stages/${stage.id}/visualize`); - } + router.push(`/musician/stages/${stage.id}`); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to create stage"); } finally { diff --git a/src/app/(musician)/musician/stages/map/page.tsx b/src/app/(musician)/musician/stages/map/page.tsx new file mode 100644 index 0000000..10956d6 --- /dev/null +++ b/src/app/(musician)/musician/stages/map/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { StageMap } from "@/components/stages/StageMap"; +import { StageMapStage } from "@/components/stages/StageMap"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { ArrowLeft, Plus, Loader2 } from "lucide-react"; + +export default function StageMapPage() { + const [stages, setStages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchStages() { + try { + const res = await fetch("/api/stages"); + const json = await res.json(); + setStages(json.data || []); + } catch { + setError("Failed to load stages"); + } finally { + setLoading(false); + } + } + fetchStages(); + }, []); + + return ( +
+ {/* Header */} +
+
+ + + +
+

Stage Map

+

+ {loading ? "Loading..." : `${stages.length} stage${stages.length !== 1 ? "s" : ""}`} +

+
+
+ + + +
+ + {/* Map */} +
+ {loading && ( +
+ +
+ )} + {error && ( +
+

{error}

+
+ )} + {!loading && !error && ( + + )} +
+
+ ); +} diff --git a/src/app/(musician)/musician/stages/new/page.tsx b/src/app/(musician)/musician/stages/new/page.tsx index 9824f4c..7040032 100644 --- a/src/app/(musician)/musician/stages/new/page.tsx +++ b/src/app/(musician)/musician/stages/new/page.tsx @@ -1,223 +1,14 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Slider } from "@/components/ui/slider"; -import { MapPin, Navigation, Loader2, CheckCircle, Map } from "lucide-react"; -import { toast } from "sonner"; -import { MapPicker } from "@/components/stages/MapPicker"; -export default function NewStagePage() { +export default function NewStageRedirect() { const router = useRouter(); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [lat, setLat] = useState(""); - const [lng, setLng] = useState(""); - const [radius, setRadius] = useState([50]); - const [loading, setLoading] = useState(false); - const [locating, setLocating] = useState(false); - const [showMap, setShowMap] = useState(false); - function useMyLocation() { - setLocating(true); - navigator.geolocation.getCurrentPosition( - (pos) => { - setLat(pos.coords.latitude); - setLng(pos.coords.longitude); - setLocating(false); - toast.success("Location captured!"); - }, - () => { - toast.error("Could not get location. Please enter manually."); - setLocating(false); - }, - { enableHighAccuracy: true } - ); - } - - // Initialize with current location or default useEffect(() => { - if (!lat && !lng && navigator.geolocation) { - setLocating(true); - navigator.geolocation.getCurrentPosition( - (pos) => { - setLat(pos.coords.latitude); - setLng(pos.coords.longitude); - setLocating(false); - }, - () => { - // Default to SF if geolocation fails - setLat(37.7749); - setLng(-122.4194); - setLocating(false); - } - ); - } - }, []); - - function handleLocationChange(newLat: number, newLng: number) { - setLat(newLat); - setLng(newLng); - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!lat || !lng) { toast.error("Latitude and longitude are required."); return; } - - setLoading(true); - const res = await fetch("/api/stages", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: name.trim(), - description: description.trim() || undefined, - latitude: lat, - longitude: lng, - radius: radius[0], - }), - }); - - setLoading(false); - - if (!res.ok) { - const err = await res.json(); - toast.error(err.error ?? "Failed to create stage."); - return; - } - - const { data } = await res.json(); - toast.success("Stage created!"); - router.push(`/musician/stages/${data.id}/visualize`); - } - - return ( -
-

Create Stage

-

Place an AR stage at a GPS location

- -
-
- - setName(e.target.value)} - placeholder="e.g. The Underground, Rooftop Rave" - required - className="bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-600 focus:border-violet-500 h-11" - /> -
- -
- - setDescription(e.target.value)} - placeholder="Optional — describe this venue" - className="bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-600 focus:border-violet-500 h-11" - /> -
- - {/* Location */} -
-
- -
- - -
-
- - {/* Map Picker */} - {showMap && lat && lng && ( - - )} - - {/* Manual coordinates input */} -
-
- - setLat(e.target.value ? parseFloat(e.target.value) : "")} - placeholder="37.7749" - required - className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-600 h-10 text-sm font-mono" - /> -
-
- - setLng(e.target.value ? parseFloat(e.target.value) : "")} - placeholder="-122.4194" - required - className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-600 h-10 text-sm font-mono" - /> -
-
- -
-
- - {radius[0]}m -
- -

Users within {radius[0]}m will see this stage in AR

-
-
+ router.replace("/musician/stages/create"); + }, [router]); - -
-
- ); + return null; } diff --git a/src/app/(musician)/musician/stages/page.tsx b/src/app/(musician)/musician/stages/page.tsx index 917d9aa..94e0d0c 100644 --- a/src/app/(musician)/musician/stages/page.tsx +++ b/src/app/(musician)/musician/stages/page.tsx @@ -28,18 +28,25 @@ export default async function StagesPage() {

Stages

{stages.length} GPS-anchored stage{stages.length !== 1 ? "s" : ""}

- - - +
+ + + + + + +
{stages.length === 0 ? (

No stages yet.

- + diff --git a/src/app/api/stages/route.ts b/src/app/api/stages/route.ts index 2bfe02e..8267612 100644 --- a/src/app/api/stages/route.ts +++ b/src/app/api/stages/route.ts @@ -59,7 +59,11 @@ export async function GET(request: NextRequest) { let stages = await prisma.stage.findMany({ where, - include: include as never, + include: { + musician: { select: { displayName: true } }, + visualizations: true, + stageTrackLinks: include.stageTrackLinks ? { include: { track: true } } : undefined, + }, orderBy: { createdAt: "desc" }, }); diff --git a/src/components/stages/MapPicker.tsx b/src/components/stages/MapPicker.tsx index 87c7141..9663756 100644 --- a/src/components/stages/MapPicker.tsx +++ b/src/components/stages/MapPicker.tsx @@ -58,6 +58,10 @@ export function MapPicker({ latitude, longitude, onLocationChange, radius = 50 } onLocationChange(lat, lng); }; + const [showManualEntry, setShowManualEntry] = useState(false); + const [manualLat, setManualLat] = useState(""); + const [manualLng, setManualLng] = useState(""); + const handleUseMyLocation = () => { if (!navigator.geolocation) { toast.error("Geolocation is not supported by your browser"); @@ -74,11 +78,24 @@ export function MapPicker({ latitude, longitude, onLocationChange, radius = 50 } (err) => { toast.error(`Could not get location: ${err.message}`); setIsLocating(false); + setShowManualEntry(true); }, { enableHighAccuracy: true } ); }; + const handleManualSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const lat = parseFloat(manualLat); + const lng = parseFloat(manualLng); + if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { + toast.error("Invalid coordinates. Lat: -90 to 90, Lng: -180 to 180"); + return; + } + handleLocationChange(lat, lng); + setShowManualEntry(false); + }; + // Calculate default center (default to SF if no coords provided) const defaultCenter: [number, number] = latitude && longitude ? [latitude, longitude] : [37.7749, -122.4194]; @@ -89,17 +106,29 @@ export function MapPicker({ latitude, longitude, onLocationChange, radius = 50 } Pin Your Stage
- +
+ + +
@@ -124,10 +153,50 @@ export function MapPicker({ latitude, longitude, onLocationChange, radius = 50 } - + {/* Radius indicator */} + + {showManualEntry && ( +
+

Enter coordinates manually

+
+ setManualLat(e.target.value)} + className="w-full px-2 py-1.5 text-xs bg-zinc-800 border border-zinc-700 rounded text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500" + /> + setManualLng(e.target.value)} + className="w-full px-2 py-1.5 text-xs bg-zinc-800 border border-zinc-700 rounded text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500" + /> +
+ + +
+
+
+ )}
From 858e41cf78f94cd6eb9924ef79c6c5de0302b107 Mon Sep 17 00:00:00 2001 From: assistant Date: Tue, 31 Mar 2026 02:50:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(mobile):=20mobile-first=20skeleton=20?= =?UTF-8?q?=E2=80=94=20viewport,=20bottom=20nav,=20safe=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(musician)/layout.tsx | 6 +-- src/app/layout.tsx | 8 +++- src/components/layout/MusicianBottomNav.tsx | 46 +++++++++++++++++++++ src/components/ui/button.tsx | 4 +- 4 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 src/components/layout/MusicianBottomNav.tsx diff --git a/src/app/(musician)/layout.tsx b/src/app/(musician)/layout.tsx index f4f2ccf..5d29e49 100644 --- a/src/app/(musician)/layout.tsx +++ b/src/app/(musician)/layout.tsx @@ -1,6 +1,6 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { MusicianSidebar } from "@/components/layout/MusicianSidebar"; +import { MusicianBottomNav } from "@/components/layout/MusicianBottomNav"; export default async function MusicianLayout({ children }: { children: React.ReactNode }) { const session = await auth(); @@ -12,8 +12,8 @@ export default async function MusicianLayout({ children }: { children: React.Rea return (
- -
{children}
+
{children}
+
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9553624..492c06c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist_Mono } from "next/font/google"; import "./globals.css"; import { Toaster } from "@/components/ui/sonner"; @@ -8,6 +8,12 @@ const mono = Geist_Mono({ subsets: ["latin"], }); +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, +}; + export const metadata: Metadata = { title: "Amplify — AR Concert Experience", description: diff --git a/src/components/layout/MusicianBottomNav.tsx b/src/components/layout/MusicianBottomNav.tsx new file mode 100644 index 0000000..3f94d6b --- /dev/null +++ b/src/components/layout/MusicianBottomNav.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Music2, MapPin, User } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const NAV_ITEMS = [ + { href: "/musician/stages", label: "Stages", Icon: MapPin }, + { href: "/musician/tracks", label: "Tracks", Icon: Music2 }, + { href: "/musician/profile", label: "Profile", Icon: User }, +]; + +export function MusicianBottomNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b5ea4ab..d20261e 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -21,9 +21,9 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", + default: "h-11 min-h-[44px] px-4 py-2 has-[>svg]:px-3", xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + sm: "h-9 min-h-[44px] rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",