feat: Direct room URL navigation + fix c3nav iframe blocking
- Add inline JoinForm when navigating directly to room URL - No longer redirects to home page for new users - Replace c3nav iframe with fallback UI (c3nav blocks iframe embedding) - Add 'Open c3nav' button to open indoor map in new tab - Still shows friend markers with indoor locations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
908767be15
commit
796fd2c727
|
|
@ -13,6 +13,7 @@ import ShareModal from '@/components/room/ShareModal';
|
||||||
import MeetingPointModal from '@/components/room/MeetingPointModal';
|
import MeetingPointModal from '@/components/room/MeetingPointModal';
|
||||||
import WaypointModal from '@/components/room/WaypointModal';
|
import WaypointModal from '@/components/room/WaypointModal';
|
||||||
import InstallBanner from '@/components/room/InstallBanner';
|
import InstallBanner from '@/components/room/InstallBanner';
|
||||||
|
import JoinForm from '@/components/room/JoinForm';
|
||||||
import type { Participant, ParticipantLocation, Waypoint } from '@/types';
|
import type { Participant, ParticipantLocation, Waypoint } from '@/types';
|
||||||
|
|
||||||
// Dynamic import for map to avoid SSR issues with MapLibre
|
// Dynamic import for map to avoid SSR issues with MapLibre
|
||||||
|
|
@ -38,24 +39,31 @@ export default function RoomPage() {
|
||||||
const [selectedWaypoint, setSelectedWaypoint] = useState<Waypoint | null>(null);
|
const [selectedWaypoint, setSelectedWaypoint] = useState<Waypoint | null>(null);
|
||||||
const [shouldAutoStartSharing, setShouldAutoStartSharing] = useState(false);
|
const [shouldAutoStartSharing, setShouldAutoStartSharing] = useState(false);
|
||||||
const [zoomToLocation, setZoomToLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
const [zoomToLocation, setZoomToLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
||||||
|
const [needsJoin, setNeedsJoin] = useState(false);
|
||||||
|
|
||||||
// Load user and sharing preference from localStorage
|
// Load user and sharing preference from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem('rmaps_user');
|
const stored = localStorage.getItem('rmaps_user');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
setCurrentUser(JSON.parse(stored));
|
setCurrentUser(JSON.parse(stored));
|
||||||
|
// Check if user had location sharing enabled for this room
|
||||||
|
const sharingPref = localStorage.getItem(`rmaps_sharing_${slug}`);
|
||||||
|
if (sharingPref === 'true') {
|
||||||
|
setShouldAutoStartSharing(true);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Redirect to home if no user info
|
// Show join form instead of redirecting
|
||||||
router.push('/');
|
setNeedsJoin(true);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
// Check if user had location sharing enabled for this room
|
// Handle joining from the inline form
|
||||||
const sharingPref = localStorage.getItem(`rmaps_sharing_${slug}`);
|
const handleJoin = (name: string, emoji: string) => {
|
||||||
if (sharingPref === 'true') {
|
const user = { name, emoji };
|
||||||
setShouldAutoStartSharing(true);
|
localStorage.setItem('rmaps_user', JSON.stringify(user));
|
||||||
}
|
setCurrentUser(user);
|
||||||
}, [router, slug]);
|
setNeedsJoin(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Room hook (only initialize when we have user info)
|
// Room hook (only initialize when we have user info)
|
||||||
const {
|
const {
|
||||||
|
|
@ -276,6 +284,11 @@ export default function RoomPage() {
|
||||||
setShowParticipants(false); // Close participant list to show map
|
setShowParticipants(false); // Close participant list to show map
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show join form if user hasn't entered their name yet
|
||||||
|
if (needsJoin) {
|
||||||
|
return <JoinForm roomSlug={slug} onJoin={handleJoin} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (!currentUser || isLoading) {
|
if (!currentUser || isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import type { Participant } from '@/types';
|
import type { Participant } from '@/types';
|
||||||
|
|
||||||
interface C3NavEmbedProps {
|
interface C3NavEmbedProps {
|
||||||
/** Event identifier (e.g., '39c3', 'eh2025') */
|
/** Event identifier (e.g., '39c3', 'eh2025') */
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
/** Initial location to show */
|
|
||||||
initialLocation?: string;
|
|
||||||
/** Participants to show on the map overlay */
|
/** Participants to show on the map overlay */
|
||||||
participants?: Participant[];
|
participants?: Participant[];
|
||||||
/** Current user ID */
|
/** Current user ID */
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
/** Callback when user taps a location */
|
|
||||||
onLocationSelect?: (location: { slug: string; name: string }) => void;
|
|
||||||
/** Show the indoor/outdoor toggle */
|
/** Show the indoor/outdoor toggle */
|
||||||
showToggle?: boolean;
|
showToggle?: boolean;
|
||||||
/** Callback when toggling to outdoor mode */
|
/** Callback when toggling to outdoor mode */
|
||||||
|
|
@ -32,105 +27,37 @@ const C3NAV_EVENTS: Record<string, string> = {
|
||||||
|
|
||||||
export default function C3NavEmbed({
|
export default function C3NavEmbed({
|
||||||
eventId = '39c3',
|
eventId = '39c3',
|
||||||
initialLocation,
|
|
||||||
participants = [],
|
participants = [],
|
||||||
currentUserId,
|
currentUserId,
|
||||||
onLocationSelect,
|
|
||||||
showToggle = true,
|
showToggle = true,
|
||||||
onToggleOutdoor,
|
onToggleOutdoor,
|
||||||
}: C3NavEmbedProps) {
|
}: C3NavEmbedProps) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Get the c3nav base URL for the event
|
// Get the c3nav base URL for the event
|
||||||
const baseUrl = C3NAV_EVENTS[eventId] || C3NAV_EVENTS['39c3'];
|
const baseUrl = C3NAV_EVENTS[eventId] || C3NAV_EVENTS['39c3'];
|
||||||
|
|
||||||
// Build the embed URL
|
// c3nav blocks iframes, so show fallback UI with link to open in new tab
|
||||||
const embedUrl = new URL(baseUrl);
|
|
||||||
embedUrl.searchParams.set('embed', '1');
|
|
||||||
if (initialLocation) {
|
|
||||||
embedUrl.searchParams.set('o', initialLocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle iframe load
|
|
||||||
const handleLoad = () => {
|
|
||||||
setIsLoading(false);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle iframe error
|
|
||||||
const handleError = () => {
|
|
||||||
setIsLoading(false);
|
|
||||||
setError('Failed to load indoor map');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for messages from c3nav iframe
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
// Only accept messages from c3nav
|
|
||||||
if (!event.origin.includes('c3nav.de')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = event.data;
|
|
||||||
if (data.type === 'c3nav:location' && onLocationSelect) {
|
|
||||||
onLocationSelect({
|
|
||||||
slug: data.slug,
|
|
||||||
name: data.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore invalid messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
return () => window.removeEventListener('message', handleMessage);
|
|
||||||
}, [onLocationSelect]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full bg-rmaps-dark">
|
||||||
{/* c3nav iframe */}
|
{/* Fallback UI since c3nav blocks iframe embedding */}
|
||||||
<iframe
|
<div className="absolute inset-0 flex flex-col items-center justify-center p-6 text-center">
|
||||||
ref={iframeRef}
|
<div className="text-6xl mb-4">🏢</div>
|
||||||
src={embedUrl.toString()}
|
<h3 className="text-xl font-semibold text-white mb-2">Indoor Navigation</h3>
|
||||||
className="w-full h-full border-0"
|
<p className="text-white/60 text-sm mb-6 max-w-xs">
|
||||||
allow="geolocation"
|
Open c3nav to view the indoor map and navigate the venue
|
||||||
onLoad={handleLoad}
|
</p>
|
||||||
onError={handleError}
|
<button
|
||||||
title="c3nav indoor navigation"
|
onClick={() => window.open(baseUrl, '_blank')}
|
||||||
/>
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
{/* Loading overlay */}
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{isLoading && (
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
<div className="absolute inset-0 bg-rmaps-dark flex items-center justify-center">
|
</svg>
|
||||||
<div className="text-center">
|
Open c3nav
|
||||||
<div className="w-8 h-8 border-2 border-rmaps-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
</button>
|
||||||
<div className="text-white/60 text-sm">Loading indoor map...</div>
|
<p className="text-white/40 text-xs mt-4">
|
||||||
</div>
|
{eventId.toUpperCase()} venue map
|
||||||
</div>
|
</p>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Error overlay */}
|
|
||||||
{error && (
|
|
||||||
<div className="absolute inset-0 bg-rmaps-dark flex items-center justify-center">
|
|
||||||
<div className="text-center p-4">
|
|
||||||
<div className="text-red-400 mb-2">{error}</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
if (iframeRef.current) {
|
|
||||||
iframeRef.current.src = embedUrl.toString();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="btn-ghost text-sm"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Friend markers overlay */}
|
{/* Friend markers overlay */}
|
||||||
{participants.length > 0 && (
|
{participants.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const EMOJIS = ['😀', '😎', '🤓', '🥳', '🦊', '🐱', '🐶', '🦄', '🌟', '🔥', '💜', '🎮'];
|
||||||
|
|
||||||
|
interface JoinFormProps {
|
||||||
|
roomSlug: string;
|
||||||
|
onJoin: (name: string, emoji: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JoinForm({ roomSlug, onJoin }: JoinFormProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [emoji, setEmoji] = useState(EMOJIS[Math.floor(Math.random() * EMOJIS.length)]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name.trim()) {
|
||||||
|
onJoin(name.trim(), emoji);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 bg-rmaps-dark">
|
||||||
|
<div className="room-panel rounded-2xl p-6 max-w-sm w-full">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">Join Room</h1>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
You're joining <span className="text-rmaps-primary font-medium">/{roomSlug}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Emoji picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/60 text-sm mb-2">Pick your avatar</label>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
{EMOJIS.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEmoji(e)}
|
||||||
|
className={`w-10 h-10 rounded-full text-xl flex items-center justify-center transition-all ${
|
||||||
|
emoji === e
|
||||||
|
? 'bg-rmaps-primary scale-110'
|
||||||
|
: 'bg-white/10 hover:bg-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/60 text-sm mb-2">Your name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/40 focus:border-rmaps-primary focus:outline-none"
|
||||||
|
maxLength={20}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="flex items-center justify-center gap-3 py-3 bg-white/5 rounded-lg">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-rmaps-primary/30 flex items-center justify-center text-2xl">
|
||||||
|
{emoji}
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{name || 'Your Name'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="w-full btn-primary py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Join Room
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Location sharing note */}
|
||||||
|
<p className="text-white/40 text-xs text-center mt-4">
|
||||||
|
Location sharing is optional and can be toggled anytime
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue