The entire skate-spots codebase was accidentally copied into src/src/ and public/public/ instead of src/ and public/ directly. This left the real src/ directory as blank MKStack template stubs, explaining why the landing page showed the default template instead of SkateSpots. Changes made: - Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx, all pages, components, hooks, contexts, lib, i18n, test) - Copied all files from public/public/ up to public/ (images, manifest, etc.) - Deleted src/src/ and public/public/ nested directories - Removed HelloWorld.tsx template stub from pages/ - Retained the previously fixed index.css (Tailwind v3 directives) Full skate-spots feature set is now active: - All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.) - NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler - Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.) - Outfit Variable font, Amber pre-check in main.tsx - All hooks (useSkateSpots, useCoaches, useZaps, etc.) - i18n support (en/es) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
313 lines
9.5 KiB
TypeScript
313 lines
9.5 KiB
TypeScript
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,
|
|
X,
|
|
RotateCcw,
|
|
Check,
|
|
Loader2,
|
|
SwitchCamera,
|
|
} from 'lucide-react';
|
|
|
|
interface CameraCaptureProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onCapture: (file: File) => void;
|
|
isUploading?: boolean;
|
|
}
|
|
|
|
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);
|
|
|
|
// Start the camera
|
|
const startCamera = useCallback(async (facing: 'user' | 'environment' = facingMode) => {
|
|
setIsStarting(true);
|
|
try {
|
|
// Stop any existing stream first
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach(track => track.stop());
|
|
}
|
|
|
|
const constraints: MediaStreamConstraints = {
|
|
video: {
|
|
facingMode: facing,
|
|
width: { ideal: 1920 },
|
|
height: { ideal: 1080 },
|
|
},
|
|
audio: false,
|
|
};
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
streamRef.current = stream;
|
|
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = stream;
|
|
await videoRef.current.play();
|
|
}
|
|
|
|
setHasPermission(true);
|
|
setFacingMode(facing);
|
|
} catch (error) {
|
|
console.error('Camera access error:', error);
|
|
setHasPermission(false);
|
|
toast({
|
|
title: 'Camera access denied',
|
|
description: 'Please allow camera access in your browser settings to take photos.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsStarting(false);
|
|
}
|
|
}, [facingMode, toast]);
|
|
|
|
// Stop the camera
|
|
const stopCamera = useCallback(() => {
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach(track => track.stop());
|
|
streamRef.current = null;
|
|
}
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = null;
|
|
}
|
|
}, []);
|
|
|
|
// Start camera when dialog opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
startCamera();
|
|
} else {
|
|
stopCamera();
|
|
setCapturedImage(null);
|
|
setHasPermission(null);
|
|
}
|
|
|
|
return () => {
|
|
stopCamera();
|
|
};
|
|
}, [open, startCamera, stopCamera]);
|
|
|
|
// Switch camera (front/back)
|
|
const switchCamera = () => {
|
|
const newFacing = facingMode === 'user' ? 'environment' : 'user';
|
|
setCapturedImage(null);
|
|
startCamera(newFacing);
|
|
};
|
|
|
|
// Capture photo
|
|
const capturePhoto = () => {
|
|
if (!videoRef.current || !canvasRef.current) return;
|
|
|
|
const video = videoRef.current;
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
if (!ctx) return;
|
|
|
|
// Set canvas dimensions to video dimensions
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
|
|
// Draw the video frame to the canvas
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
// Get the data URL
|
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
|
setCapturedImage(dataUrl);
|
|
};
|
|
|
|
// Retake photo
|
|
const retakePhoto = () => {
|
|
setCapturedImage(null);
|
|
};
|
|
|
|
// Confirm and upload photo
|
|
const confirmPhoto = async () => {
|
|
if (!capturedImage || !canvasRef.current) return;
|
|
|
|
// Convert data URL to Blob then to File
|
|
const response = await fetch(capturedImage);
|
|
const blob = await response.blob();
|
|
const file = new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
|
|
onCapture(file);
|
|
};
|
|
|
|
// Handle close
|
|
const handleClose = () => {
|
|
stopCamera();
|
|
setCapturedImage(null);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
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">
|
|
{/* Hidden canvas for capturing */}
|
|
<canvas ref={canvasRef} className="hidden" />
|
|
|
|
{/* Loading state */}
|
|
{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 state */}
|
|
{hasPermission === false && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black">
|
|
<div className="text-center p-6">
|
|
<Camera className="h-12 w-12 text-gray-600 mx-auto mb-4" />
|
|
<h3 className="text-white font-bold mb-2">Camera Access Required</h3>
|
|
<p className="text-gray-400 text-sm mb-4">
|
|
Please allow camera access in your browser to take photos.
|
|
</p>
|
|
<Button
|
|
onClick={() => startCamera()}
|
|
className="bg-[#ff0040] hover:bg-[#ff0040]/90"
|
|
>
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Video feed */}
|
|
{!capturedImage && (
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
className={cn(
|
|
'w-full h-full object-cover',
|
|
(isStarting || hasPermission === false) && 'hidden'
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Captured image preview */}
|
|
{capturedImage && (
|
|
<img
|
|
src={capturedImage}
|
|
alt="Captured"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
|
|
{/* Camera switch 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>
|
|
)}
|
|
|
|
{/* Crosshairs/guides */}
|
|
{!capturedImage && hasPermission && !isStarting && (
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
{/* Grid lines */}
|
|
<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={retakePhoto}
|
|
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>
|
|
);
|
|
}
|