immersive2/src/react/pages/vrExperience.tsx
Michael Mainguy 1e174e81d3
Some checks failed
Node.js CI / build (push) Waiting to run
Build / build (push) Failing after 15m8s
Add local database mode for browser-only diagrams
- Add /db/local/:db path type that stores diagrams locally without syncing
- New diagrams now default to local storage (browser-only)
- Share button creates public copy when sharing local diagrams
- Add storage type badges (Local/Public/Private) in diagram manager
- Add GitHub Actions workflow for automated builds
- Block local- database requests at server with 404

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:13:43 -06:00

478 lines
20 KiB
TypeScript

import VrApp from '../../vrApp';
import React, {useEffect, useState} from "react";
import {Affix, Burger, Group, Menu, Alert, Button, Text} from "@mantine/core";
import VrTemplate from "../vrTemplate";
import {IconStar, IconInfoCircle, IconMessageCircle} from "@tabler/icons-react";
import VrMenuItem from "../components/vrMenuItem";
import CreateDiagramModal from "./createDiagramModal";
import ManageDiagramsModal from "./manageDiagramsModal";
import {useNavigate, useParams} from "react-router-dom";
import {useDisclosure} from "@mantine/hooks";
import ConfigModal from "./configModal";
import log from "loglevel";
import {useFeatureState, useUserTier} from "../hooks/useFeatures";
import {useAuth0} from "@auth0/auth0-react";
import {GUEST_MODE_BANNER} from "../../content/upgradeCopy";
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
import {isMobileVRDevice} from "../../util/deviceDetection";
import {DefaultScene} from "../../defaultScene";
import VREntryPrompt from "../components/VREntryPrompt";
import ComingSoonBadge from "../components/ComingSoonBadge";
import UpgradeBadge from "../components/UpgradeBadge";
import ChatPanel from "../components/ChatPanel";
import {getDbType} from "../../util/functions/getPath";
import PouchDB from 'pouchdb';
import {v4} from "uuid";
let vrApp: VrApp = null;
const defaultCreate = window.localStorage.getItem('createOpened') === 'true';
const defaultConfig = window.localStorage.getItem('configOpened') === 'true';
const defaultManage = window.localStorage.getItem('manageOpened') === 'true';
const defaultMenuOpened = window.localStorage.getItem('menuOpened') !== 'false'; // Default to true (open)
export default function VrExperience() {
const logger = log.getLogger('vrExperience');
const params = useParams();
const { isAuthenticated, loginWithRedirect } = useAuth0();
const [guestBannerDismissed, setGuestBannerDismissed] = useState(false);
const [menuOpened, setMenuOpened] = useState(defaultMenuOpened);
// Feature flags - get states instead of just enabled boolean
const createDiagramState = useFeatureState('createDiagram');
const createFromTemplateState = useFeatureState('createFromTemplate');
const manageDiagramsState = useFeatureState('manageDiagrams');
const shareCollaborateState = useFeatureState('shareCollaborate');
console.log('[Share] shareCollaborateState:', shareCollaborateState);
const editDataState = useFeatureState('editData');
const configState = useFeatureState('config');
const enterImmersiveState = useFeatureState('enterImmersive');
const launchMetaQuestState = useFeatureState('launchMetaQuest');
const userTier = useUserTier();
// Helper to check if feature should be shown (not 'off')
const shouldShow = (state) => state !== 'off';
const isEnabled = (state) => state === 'on';
// Get the appropriate click handler based on feature state
const getClickHandler = (state, enabledHandler) => {
if (state === 'on') {
return enabledHandler; // Feature is enabled, use the normal handler
}
if (state === 'basic') {
return handleSignUp; // Feature requires sign up, trigger auth
}
// For 'coming-soon', 'pro', or other states, no click handler
return null;
};
const handleSignUp = () => {
loginWithRedirect({
appState: { returnTo: window.location.pathname }
});
};
const handleExportJSON = async () => {
try {
await exportDiagramAsJSON(dbName);
logger.info('Diagram exported successfully');
} catch (error) {
logger.error('Failed to export diagram:', error);
}
};
const saveState = (key, value) => {
logger.debug('saving', key, value)
window.localStorage.setItem(key, value ? 'true' : 'false');
}
const toggleMenu = () => {
const newState = !menuOpened;
setMenuOpened(newState);
window.localStorage.setItem('menuOpened', newState ? 'true' : 'false');
};
const [createOpened, {open: openCreate, close: closeCreate}] =
useDisclosure(defaultCreate,
{
onOpen: () => {
saveState('createOpened', true)
}, onClose: () => {
saveState('createOpened', false)
}
});
const [manageOpened, {open: openManage, close: closeManage}] = useDisclosure(
defaultManage,
{
onOpen: () => {
saveState('manageOpened', true)
}, onClose: () => {
saveState('manageOpened', false)
}
})
const [configOpened, {open: openConfig, close: closeConfig}] =
useDisclosure(
defaultConfig,
{
onOpen: () => {
saveState('configOpened', true)
}, onClose: () => {
saveState('configOpened', false)
}
})
const [rerender, setRerender] = useState(0);
const [dbName, setDbName] = useState(params.db);
const [showVRPrompt, setShowVRPrompt] = useState(false);
const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop
// Handle share based on current database type:
// - Public: copy current URL to clipboard
// - Local: create one-time copy to public, share new URL
// - Private: show "not yet supported" message
const handleShare = async () => {
const dbType = getDbType();
logger.info(`[Share] Sharing diagram - type: ${dbType}, dbName: ${dbName}`);
if (dbType === 'private') {
alert('Sharing private diagrams is not yet supported.\n\nPrivate diagram sharing with access control is coming soon!');
return;
}
if (dbType === 'local') {
// Create a one-time copy to public
logger.info('[Share] Creating public copy of local diagram...');
try {
// Generate new ID for the public copy
const publicDbName = 'diagram-' + v4();
const localDb = new PouchDB(dbName);
const remoteUrl = `${window.location.origin}/pouchdb/public-${publicDbName}`;
const remoteDb = new PouchDB(remoteUrl);
// Get all docs from local database
const allDocs = await localDb.allDocs({ include_docs: true });
logger.debug(`[Share] Found ${allDocs.rows.length} documents to copy`);
// Copy each document to the remote database
for (const row of allDocs.rows) {
if (row.doc) {
// Remove PouchDB internal fields for clean insert
const { _rev, ...docWithoutRev } = row.doc as any;
try {
await remoteDb.put(docWithoutRev);
} catch (err) {
logger.warn(`[Share] Failed to copy doc ${row.id}:`, err);
}
}
}
const publicUrl = `${window.location.origin}/db/public/${publicDbName}`;
logger.info(`[Share] Public copy created at: ${publicUrl}`);
// Copy URL to clipboard
let copied = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(publicUrl);
copied = true;
}
} catch (clipboardError) {
logger.warn('Clipboard API failed:', clipboardError);
}
if (copied) {
alert(`Public copy created!\n\nURL copied to clipboard:\n${publicUrl}\n\nNote: Your local diagram remains unchanged. The public copy will diverge independently.`);
} else {
prompt('Public copy created! Share URL (copy manually):', publicUrl);
}
} catch (err) {
logger.error('[Share] Failed to create public copy:', err);
alert('Failed to create public copy. Please try again.');
}
return;
}
// Public diagram - just copy current URL
const shareUrl = window.location.href;
logger.info(`[Share] Sharing public URL: ${shareUrl}`);
// Try to copy URL to clipboard with fallback
let copied = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
copied = true;
}
} catch (clipboardError) {
logger.warn('Clipboard API failed:', clipboardError);
}
if (copied) {
alert(`URL copied to clipboard!\n\n${shareUrl}`);
} else {
// Fallback: show URL in prompt so user can copy manually
prompt('Share URL (copy manually):', shareUrl);
}
};
useEffect(() => {
const canvas = document.getElementById('vrCanvas');
if (!canvas) {
logger.error('no canvas');
return;
}
if (vrApp) {
logger.debug('destroying vrApp');
vrApp.dispose();
}
console.log('[Share] Initializing VrApp with dbName:', dbName);
vrApp = new VrApp(canvas as HTMLCanvasElement, dbName);
closeManage();
// Show VR entry prompt for all Quest users navigating to any /db/** path
const isQuest = isMobileVRDevice();
logger.info(`Device check: isMobileVRDevice=${isQuest}, userAgent=${navigator.userAgent}`);
if (isQuest) {
logger.info('Quest device detected, will show VR prompt when ready');
// Wait for XR to be ready, then show the prompt
let attempts = 0;
const maxAttempts = 50;
const waitForXRReady = setInterval(() => {
attempts++;
const scene = DefaultScene.Scene;
const groundMesh = scene?.getMeshByName('ground');
logger.debug(`XR readiness check attempt ${attempts}: scene=${!!scene}, groundMesh=${!!groundMesh}`);
if (groundMesh || attempts >= maxAttempts) {
clearInterval(waitForXRReady);
if (groundMesh) {
logger.info('XR ready, showing VR entry prompt');
setShowVRPrompt(true);
logger.info(`showVRPrompt state set to true`);
} else {
logger.warn('XR setup timeout, cannot show VR prompt');
}
}
}, 500);
}
}, [dbName]);
const [immersiveDisabled, setImmersiveDisabled] = useState(true);
const navigate = useNavigate();
// Get the appropriate indicator for a feature based on its state
const getFeatureIndicator = (featureState) => {
// 'off' - don't show at all (handled by shouldShow)
// 'on' - no badge needed, feature is fully accessible
// 'coming-soon' - show Coming Soon badge (visible to all)
// 'basic' - show Sign Up badge (requires basic tier) - clickable to sign up
// 'pro' - show Upgrade to Pro badge (requires pro tier)
if (featureState === 'coming-soon') {
return <ComingSoonBadge size="xs" />;
}
if (featureState === 'basic') {
return <UpgradeBadge size="xs" tier="basic" onClick={handleSignUp} />;
}
if (featureState === 'pro') {
return <UpgradeBadge size="xs" tier="pro" />;
}
// 'on' state - no indicator needed
return null;
}
const enterImmersive = (e) => {
logger.info('entering immersive mode');
e.preventDefault();
const event = new CustomEvent('enterXr', {bubbles: true});
window.dispatchEvent(event);
}
const createModal = () => {
if (createOpened) {
return <CreateDiagramModal createOpened={createOpened} closeCreate={closeCreate}/>
} else {
return <></>
}
}
const manageModal = () => {
if (manageOpened) {
return <ManageDiagramsModal openCreate={openCreate}
manageOpened={manageOpened}
closeManage={closeManage}
/>
} else {
return <></>
}
}
return (
<React.StrictMode>
<VrTemplate>
{/* Guest Mode Banner - Non-aggressive, dismissible (hidden for demo) */}
{!isAuthenticated && !guestBannerDismissed && dbName !== 'demo' && (
<Affix position={{top: 20, right: 20}} style={{maxWidth: 400}} zIndex={100}>
<Alert
variant="light"
color="blue"
title={GUEST_MODE_BANNER.title}
icon={<IconInfoCircle size={20} />}
withCloseButton
onClose={() => setGuestBannerDismissed(true)}
>
<Text size="sm" mb="xs">
{GUEST_MODE_BANNER.message}
</Text>
<Button
size="xs"
onClick={handleSignUp}
variant="light"
>
{GUEST_MODE_BANNER.ctaText}
</Button>
</Alert>
</Affix>
)}
<ConfigModal closeConfig={closeConfig} configOpened={configOpened}/>
{createModal()}
{manageModal()}
<Affix position={{top: 30, left: 60}} zIndex={100}>
<Menu opened={menuOpened} onChange={setMenuOpened} position="bottom-start" zIndex={100}>
<Menu.Target>
<Burger opened={menuOpened} onClick={toggleMenu} size="xl"/>
</Menu.Target>
<Menu.Dropdown>
{/* Home is always visible */}
<VrMenuItem
tip={"Exit modeling environment and go back to main site"}
onClick={() => {
navigate("/")
}}
label="Home"
availableIcon={null}/>
{shouldShow(enterImmersiveState) && (
<VrMenuItem
tip={immersiveDisabled ? "Browser does not support WebXR. Immersive experience best viewed with Meta Quest headset" : "Enter Immersive Mode"}
onClick={getClickHandler(enterImmersiveState, enterImmersive)}
label="Enter Immersive Mode"
availableIcon={getFeatureIndicator(enterImmersiveState)}/>
)}
{shouldShow(launchMetaQuestState) && (
<VrMenuItem
tip="Open a new window and automatically send experience to your Meta Quest headset"
onClick={getClickHandler(launchMetaQuestState, () => {
window.open('https://www.oculus.com/open_url/?url=' + window.location.href, 'launchQuest', 'popup')
})}
label="Launch On Meta Quest"
availableIcon={getFeatureIndicator(launchMetaQuestState)}/>
)}
{shouldShow(editDataState) && (
<>
<Menu.Divider/>
<VrMenuItem
tip="Edit data on desktop (Best for large amounts of text or images). After adding data, you can enter immersive mode to further refine the model."
label="Edit Data"
onClick={getClickHandler(editDataState, null)}
availableIcon={getFeatureIndicator(editDataState)}/>
</>
)}
{(shouldShow(createDiagramState) || shouldShow(createFromTemplateState) || shouldShow(manageDiagramsState)) && <Menu.Divider/>}
{shouldShow(createDiagramState) && (
<VrMenuItem
tip="Create a new diagram from scratch"
label="Create"
onClick={getClickHandler(createDiagramState, openCreate)}
availableIcon={getFeatureIndicator(createDiagramState)}/>
)}
{shouldShow(createFromTemplateState) && (
<VrMenuItem
tip="Create a new diagram from predefined template"
label="Create From Template"
onClick={getClickHandler(createFromTemplateState, null)}
availableIcon={getFeatureIndicator(createFromTemplateState)}/>
)}
{shouldShow(manageDiagramsState) && (
<VrMenuItem
tip="Manage Diagrams"
label="Manage"
onClick={getClickHandler(manageDiagramsState, openManage)}
availableIcon={getFeatureIndicator(manageDiagramsState)}/>
)}
{/* Export JSON - Always available for creating templates */}
<VrMenuItem
tip="Export current diagram as JSON file (useful for creating templates)"
label="Export JSON"
onClick={handleExportJSON}
availableIcon={null}/>
{(shouldShow(shareCollaborateState) || shouldShow(configState)) && <Menu.Divider/>}
{shouldShow(shareCollaborateState) && (
<VrMenuItem
tip="Share your model with others. Creates a shareable link that copies to clipboard."
label="Share"
onClick={getClickHandler(shareCollaborateState, handleShare)}
availableIcon={getFeatureIndicator(shareCollaborateState)}/>
)}
{shouldShow(configState) && (
<VrMenuItem
tip="Configure settings for your VR experience"
label="Config"
onClick={getClickHandler(configState, openConfig)}
availableIcon={getFeatureIndicator(configState)}/>
)}
<Menu.Divider/>
<VrMenuItem
tip="Toggle AI chat assistant for creating entities"
label={chatOpen ? "Hide Chat" : "Show Chat"}
onClick={() => setChatOpen(!chatOpen)}
availableIcon={<IconMessageCircle size={16}/>}/>
</Menu.Dropdown>
</Menu>
</Affix>
<div style={{display: 'flex', height: '100vh', width: '100vw', overflow: 'hidden'}}>
<div style={{flex: 1, position: 'relative', minWidth: 0}}>
<canvas id="vrCanvas" style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1
}}/>
</div>
{chatOpen && <ChatPanel onClose={() => setChatOpen(false)}/>}
</div>
{/* VR Entry Prompt - Rendered AFTER canvas to ensure it's on top in DOM order */}
<VREntryPrompt
isVisible={showVRPrompt}
onEnterVR={() => {
setShowVRPrompt(false);
const event = new CustomEvent('enterXr', {bubbles: true});
window.dispatchEvent(event);
}}
onSkip={() => setShowVRPrompt(false)}
/>
</VrTemplate>
</React.StrictMode>
)
}