SkateSpots/src/components/CameraCapture.tsx
shakespeare.diy 6331a937f2 feat: map as homepage, 3D globe, camera fix, Sasquatch upgrades, UI/UX polish
## 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>
2026-07-22 22:47:39 +07:00

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>
);
}