Quick Snap: camera-first on native (runtime Camera plugin), file input on web
The previous file-input-only approach fixed the crash but opened the gallery on native because capture="environment" is only a hint the OS can ignore. New platform-aware flow (lib/quickCapture.ts): - Native (APK): open the OS camera directly via the runtime-injected window.Capacitor.Plugins.Camera with source:'CAMERA' — forces the real camera and gives built-in preview/retake. Accessed off the runtime object, so no '@capacitor/camera' specifier in the bundle (keeps the white-screen fix intact). User cancel is treated as a no-op. - Web: falls back to the file input with capture="environment". - If the native camera errors, gracefully falls back to the file input. MapPage Quick Snap now routes through handleQuickSnap() → the captured photo feeds the existing upload → create-spot flow unchanged. Bump 1.0.69. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
2a55d1b3df
commit
6d0480961c
3 changed files with 102 additions and 11 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.68",
|
"version": "1.0.69",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
70
src/lib/quickCapture.ts
Normal file
70
src/lib/quickCapture.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* Quick Snap capture helper — platform-aware, camera-first.
|
||||||
|
*
|
||||||
|
* NATIVE (Capacitor APK): uses the runtime-injected Camera plugin
|
||||||
|
* (window.Capacitor.Plugins.Camera) with source:'CAMERA', which forces the
|
||||||
|
* real OS camera and ships its own preview + retake UI. We read the plugin
|
||||||
|
* off the runtime object rather than importing '@capacitor/camera', so no
|
||||||
|
* bare specifier ends up in the bundle (that was the white-screen cause).
|
||||||
|
*
|
||||||
|
* WEB: there is no reliable way to force the camera from JS, so the caller
|
||||||
|
* falls back to a file input with capture="environment" (a hint the browser
|
||||||
|
* may honor). This module only handles the native path; isNativeCapture()
|
||||||
|
* tells the caller which route to take.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CapacitorCameraPlugin {
|
||||||
|
getPhoto(options: {
|
||||||
|
resultType: string;
|
||||||
|
source: string;
|
||||||
|
quality: number;
|
||||||
|
correctOrientation: boolean;
|
||||||
|
}): Promise<{ dataUrl?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNativeCamera(): CapacitorCameraPlugin | null {
|
||||||
|
const cap = (window as unknown as {
|
||||||
|
Capacitor?: {
|
||||||
|
isNativePlatform?: () => boolean;
|
||||||
|
Plugins?: { Camera?: CapacitorCameraPlugin };
|
||||||
|
};
|
||||||
|
}).Capacitor;
|
||||||
|
if (!cap?.isNativePlatform?.()) return null;
|
||||||
|
return cap.Plugins?.Camera ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the native Camera plugin is available (i.e. inside the app). */
|
||||||
|
export function isNativeCapture(): boolean {
|
||||||
|
return getNativeCamera() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the native camera and return the captured photo as a File.
|
||||||
|
* Returns null if the user cancels. Throws if the plugin is unavailable or
|
||||||
|
* errors (caller should fall back to the web file input).
|
||||||
|
*/
|
||||||
|
export async function captureFromNativeCamera(): Promise<File | null> {
|
||||||
|
const Camera = getNativeCamera();
|
||||||
|
if (!Camera) throw new Error('Native camera unavailable');
|
||||||
|
|
||||||
|
let photo: { dataUrl?: string };
|
||||||
|
try {
|
||||||
|
photo = await Camera.getPhoto({
|
||||||
|
resultType: 'dataUrl', // CameraResultType.DataUrl
|
||||||
|
source: 'CAMERA', // CameraSource.Camera — forces the camera, not the gallery
|
||||||
|
quality: 90,
|
||||||
|
correctOrientation: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Capacitor throws with a "cancel" message when the user backs out — treat
|
||||||
|
// that as a benign null rather than an error.
|
||||||
|
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||||
|
if (msg.includes('cancel')) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,7 @@ import { SpotChat } from '@/components/SpotChat';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { isNativeCapture, captureFromNativeCamera } from '@/lib/quickCapture';
|
||||||
|
|
||||||
|
|
||||||
type ViewMode = 'map' | 'list';
|
type ViewMode = 'map' | 'list';
|
||||||
|
|
@ -604,10 +605,10 @@ export default function MapPage() {
|
||||||
// Mobile layers popover
|
// Mobile layers popover
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
|
|
||||||
// Quick Snap — uses the native OS camera/gallery picker via a file input.
|
// Quick Snap — camera-first. On native (the APK) we open the OS camera
|
||||||
// This is more robust than a custom in-app camera (no Capacitor camera
|
// directly via the runtime Camera plugin (with its built-in preview/retake).
|
||||||
// permission plumbing) and works identically on web and native. A single
|
// On web there's no reliable way to force the camera from JS, so we fall back
|
||||||
// photo is all a spot snap needs.
|
// to a file input hinting the rear camera. A single photo is all a spot needs.
|
||||||
const quickSnapInputRef = useRef<HTMLInputElement>(null);
|
const quickSnapInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile();
|
const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -762,7 +763,27 @@ export default function MapPage() {
|
||||||
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`);
|
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Native file-input change → hand the chosen photo to the upload flow.
|
// Quick Snap entry point — camera-first on native, file input on web.
|
||||||
|
const handleQuickSnap = async () => {
|
||||||
|
setAddDialOpen(false);
|
||||||
|
if (isNativeCapture()) {
|
||||||
|
try {
|
||||||
|
const file = await captureFromNativeCamera();
|
||||||
|
if (file) handleQuickCameraCapture(file);
|
||||||
|
return; // handled natively
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[QuickSnap] native camera failed, falling back to file input:', err);
|
||||||
|
toast({
|
||||||
|
title: 'Camera unavailable',
|
||||||
|
description: 'Opening the photo picker instead.',
|
||||||
|
});
|
||||||
|
// fall through to the web/file-input path below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quickSnapInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Web file-input change → hand the chosen photo to the upload flow.
|
||||||
const handleQuickSnapInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleQuickSnapInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
// Reset the input so picking the same file again still fires onChange.
|
// Reset the input so picking the same file again still fires onChange.
|
||||||
|
|
@ -1709,9 +1730,9 @@ export default function MapPage() {
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{/* Quick Snap — opens the native camera/gallery picker */}
|
{/* Quick Snap — camera-first (native) / file-input (web) */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setAddDialOpen(false); quickSnapInputRef.current?.click(); }}
|
onClick={handleQuickSnap}
|
||||||
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||||
style={{ animationDelay: '200ms' }}
|
style={{ animationDelay: '200ms' }}
|
||||||
>
|
>
|
||||||
|
|
@ -1802,9 +1823,9 @@ export default function MapPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Snap — hidden native camera/gallery input.
|
{/* Quick Snap — web fallback input (native uses the Camera plugin above).
|
||||||
`capture="environment"` hints the rear camera on phones; on desktop
|
`capture="environment"` hints the rear camera; browsers may honor it
|
||||||
it falls back to a normal file picker. Single photo is all we need. */}
|
or show a chooser. Single photo is all we need. */}
|
||||||
<input
|
<input
|
||||||
ref={quickSnapInputRef}
|
ref={quickSnapInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue