## 1. Map as Homepage - `/` now renders MapPage (the globe/map) — removed old Index homepage - `/home` redirects to Index (legacy), `/map` alias still works - Navbar: removed HOME link, SPOTS (MapPin) is now first nav item - isActive() treats both `/` and `/map` as active for SPOTS tab ## 2. 3D Globe View - New SkateGlobe component using pure Three.js (no h3-js/CDN issues) - Earth night texture with atmospheric neon red glow - Spot pins (red) + shop pins (lime) on globe surface - Click pin → zoom camera in + show info card - Drag to rotate, scroll/pinch to zoom, auto-rotation when idle - Globe toggle button added to map/list view switcher - Selected marker ring + info card with View Spot/Shop links - Lazy-loaded with Suspense skeleton ## 3. Settings → Stash Link - Added My Stash card in Settings > Wallet tab - Links to /stash with Package icon and description - Smooth hover animation with ChevronRight ## 4. Camera Fix (Capacitor Native) - CameraCapture now detects native Capacitor platform at runtime - On native Android/iOS: uses @capacitor/camera (OS-level camera, no WebView permission popup) - On browser: falls back to getUserMedia stream (existing behaviour) - Added Upload from Gallery fallback when camera permission denied - Better error handling for user cancellation ## 5. Sasquatch Upgrades - FAQ page: replaced static accordion wall with Sasquatch-powered UI - Quick question chips that pre-load questions into Sasquatch - "Open The Sasquatch" CTA button - Quick facts grid, clean modern design - About page: added Sasquatch CTA card at bottom - SasquatchGuide: listens for sasquatch:open and sasquatch:ask custom events - External pages can now trigger and pre-fill the Sasquatch - Sasquatch now shows even without a page-level message prop (for external triggers) ## 6. UI/UX Audit & Polish - Focus rings: on-brand red (WCAG accessible) - Card hover micro-interactions: subtle translateY + shadow - Button/link transition consistency (150ms ease) - Reduced motion support (@media prefers-reduced-motion) - Modal backdrop blur improvement - Skeleton shimmer animation utility - Globe CSS utilities (touch-action: none for canvas) - Typography helpers (text-balance, text-pretty) - Improved map-full-height responsive calculation Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
/**
|
|
* CameraCapture — unified camera for browser + native Capacitor app
|
|
*
|
|
* On native (Android / iOS via Capacitor):
|
|
* → Uses @capacitor/camera which calls the OS native camera directly.
|
|
* No WebView permission popup, no getUserMedia, no dialog needed.
|
|
* The user sees the full native camera UI, then returns here with the photo.
|
|
*
|
|
* On browser (desktop / mobile web):
|
|
* → Falls back to getUserMedia stream inside this dialog component.
|
|
*/
|
|
|
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
Camera,
|
|
RotateCcw,
|
|
Check,
|
|
Loader2,
|
|
SwitchCamera,
|
|
Upload,
|
|
} from 'lucide-react';
|
|
|
|
// ─── Capacitor detection ────────────────────────────────────────────────────
|
|
// We dynamically import Capacitor to avoid breaking the web build.
|
|
// On native this module exists; on web it throws/returns isNativePlatform=false.
|
|
|
|
async function isNative(): Promise<boolean> {
|
|
try {
|
|
const { Capacitor } = await import('@capacitor/core');
|
|
return Capacitor.isNativePlatform();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function captureNative(): Promise<File | null> {
|
|
const { Camera, CameraResultType, CameraSource } = await import('@capacitor/camera');
|
|
const photo = await Camera.getPhoto({
|
|
resultType: CameraResultType.DataUrl,
|
|
source: CameraSource.Camera,
|
|
quality: 90,
|
|
correctOrientation: true,
|
|
});
|
|
if (!photo.dataUrl) return null;
|
|
const res = await fetch(photo.dataUrl);
|
|
const blob = await res.blob();
|
|
return new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
}
|
|
|
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
|
|
interface CameraCaptureProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onCapture: (file: File) => void;
|
|
isUploading?: boolean;
|
|
}
|
|
|
|
// ─── Component ──────────────────────────────────────────────────────────────
|
|
|
|
export function CameraCapture({
|
|
open,
|
|
onOpenChange,
|
|
onCapture,
|
|
isUploading,
|
|
}: CameraCaptureProps) {
|
|
const { toast } = useToast();
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
|
|
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
|
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
|
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment');
|
|
const [isStarting, setIsStarting] = useState(false);
|
|
const [isCapturingNative, setIsCapturingNative] = useState(false);
|
|
|
|
// ── Native capture (Capacitor) ────────────────────────────────────────────
|
|
const handleNativeCapture = useCallback(async () => {
|
|
setIsCapturingNative(true);
|
|
try {
|
|
const file = await captureNative();
|
|
if (file) {
|
|
onCapture(file);
|
|
onOpenChange(false);
|
|
}
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
// User cancelled — not an error
|
|
if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('user cancel')) {
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
toast({
|
|
title: 'Camera error',
|
|
description: 'Could not open the camera. Please check app permissions in Settings.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsCapturingNative(false);
|
|
}
|
|
}, [onCapture, onOpenChange, toast]);
|
|
|
|
// ── Browser camera ────────────────────────────────────────────────────────
|
|
const startCamera = useCallback(async (facing: 'user' | 'environment' = facingMode) => {
|
|
setIsStarting(true);
|
|
try {
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach(track => track.stop());
|
|
}
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: facing, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
|
audio: false,
|
|
});
|
|
streamRef.current = stream;
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = stream;
|
|
await videoRef.current.play();
|
|
}
|
|
setHasPermission(true);
|
|
setFacingMode(facing);
|
|
} catch {
|
|
setHasPermission(false);
|
|
toast({
|
|
title: 'Camera access denied',
|
|
description: 'Please allow camera access in your browser settings.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsStarting(false);
|
|
}
|
|
}, [facingMode, toast]);
|
|
|
|
const stopCamera = useCallback(() => {
|
|
streamRef.current?.getTracks().forEach(track => track.stop());
|
|
streamRef.current = null;
|
|
if (videoRef.current) videoRef.current.srcObject = null;
|
|
}, []);
|
|
|
|
// On native: immediately trigger the native camera when the dialog opens
|
|
// On web: start the stream
|
|
useEffect(() => {
|
|
if (!open) {
|
|
stopCamera();
|
|
setCapturedImage(null);
|
|
setHasPermission(null);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
isNative().then((native) => {
|
|
if (cancelled) return;
|
|
if (native) {
|
|
handleNativeCapture();
|
|
} else {
|
|
startCamera();
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
stopCamera();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open]);
|
|
|
|
const switchCamera = () => {
|
|
const next = facingMode === 'user' ? 'environment' : 'user';
|
|
setCapturedImage(null);
|
|
startCamera(next);
|
|
};
|
|
|
|
const capturePhoto = () => {
|
|
if (!videoRef.current || !canvasRef.current) return;
|
|
const video = videoRef.current;
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
setCapturedImage(canvas.toDataURL('image/jpeg', 0.9));
|
|
};
|
|
|
|
const confirmPhoto = async () => {
|
|
if (!capturedImage || !canvasRef.current) return;
|
|
const res = await fetch(capturedImage);
|
|
const blob = await res.blob();
|
|
const file = new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
onCapture(file);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
stopCamera();
|
|
setCapturedImage(null);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
// ── Native in-progress screen ─────────────────────────────────────────────
|
|
// While native camera is active this dialog acts as a thin loading screen.
|
|
if (isCapturingNative) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-[340px] bg-black border-[#ff0040]/50">
|
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
<div className="w-16 h-16 rounded-full bg-[#ff0040]/20 flex items-center justify-center">
|
|
<Camera className="h-8 w-8 text-[#ff0040]" />
|
|
</div>
|
|
<p className="text-white font-bold text-center">Opening camera…</p>
|
|
<p className="text-gray-400 text-sm text-center">Use your phone camera, then return here</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ── Browser camera dialog ─────────────────────────────────────────────────
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-[500px] p-0 overflow-hidden bg-black border-[#ff0040]/50">
|
|
<DialogHeader className="p-4 border-b border-gray-800">
|
|
<DialogTitle className="flex items-center gap-2 text-white">
|
|
<Camera className="h-5 w-5 text-[#ff0040]" />
|
|
Take a Photo
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="relative aspect-[4/3] bg-black">
|
|
<canvas ref={canvasRef} className="hidden" />
|
|
|
|
{/* Loading */}
|
|
{isStarting && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
|
<div className="text-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-[#ccff00] mx-auto mb-2" />
|
|
<p className="text-gray-400 text-sm">Starting camera...</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* No permission */}
|
|
{hasPermission === false && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
|
<div className="text-center p-6 space-y-4">
|
|
<Camera className="h-12 w-12 text-gray-600 mx-auto" />
|
|
<div>
|
|
<h3 className="text-white font-bold mb-2">Camera Access Required</h3>
|
|
<p className="text-gray-400 text-sm">
|
|
Allow camera access in your browser settings, or use the file picker below.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Button onClick={() => startCamera()} className="bg-[#ff0040] hover:bg-[#ff0040]/90 gap-2">
|
|
<Camera className="h-4 w-4" />
|
|
Try Again
|
|
</Button>
|
|
<label className="cursor-pointer">
|
|
<Button variant="outline" className="gap-2 w-full border-gray-700" asChild>
|
|
<span>
|
|
<Upload className="h-4 w-4" />
|
|
Upload from Gallery
|
|
</span>
|
|
</Button>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) { onCapture(file); handleClose(); }
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Video feed */}
|
|
{!capturedImage && (
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
className={cn(
|
|
'w-full h-full object-cover',
|
|
(isStarting || hasPermission === false) && 'hidden'
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Captured preview */}
|
|
{capturedImage && (
|
|
<img src={capturedImage} alt="Captured" className="w-full h-full object-cover" />
|
|
)}
|
|
|
|
{/* Switch camera button */}
|
|
{!capturedImage && hasPermission && !isStarting && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={switchCamera}
|
|
className="absolute top-2 right-2 bg-black/50 hover:bg-black/70 text-white"
|
|
>
|
|
<SwitchCamera className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
|
|
{/* Grid lines */}
|
|
{!capturedImage && hasPermission && !isStarting && (
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3">
|
|
<div className="border-r border-white/20" />
|
|
<div className="border-r border-white/20" />
|
|
<div />
|
|
<div className="border-r border-t border-white/20" />
|
|
<div className="border-r border-t border-white/20" />
|
|
<div className="border-t border-white/20" />
|
|
<div className="border-r border-t border-white/20" />
|
|
<div className="border-r border-t border-white/20" />
|
|
<div className="border-t border-white/20" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="p-4 bg-black border-t border-gray-800">
|
|
{!capturedImage ? (
|
|
<div className="flex items-center justify-center gap-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
className="border-gray-700 text-gray-400 hover:bg-gray-900"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={capturePhoto}
|
|
disabled={!hasPermission || isStarting}
|
|
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white px-8 h-12 rounded-full"
|
|
>
|
|
<Camera className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center gap-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setCapturedImage(null)}
|
|
disabled={isUploading}
|
|
className="gap-2 border-gray-700 text-gray-400 hover:bg-gray-900"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
Retake
|
|
</Button>
|
|
<Button
|
|
onClick={confirmPhoto}
|
|
disabled={isUploading}
|
|
className="gap-2 bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold"
|
|
>
|
|
{isUploading ? (
|
|
<><Loader2 className="h-4 w-4 animate-spin" />Uploading...</>
|
|
) : (
|
|
<><Check className="h-4 w-4" />Use Photo</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|