forked from jsnbuchanan/crowd-funder-for-time-pwa
- Remove unnecessary generic type parameter from AbsurdSqlDatabaseService - Fix type handling in operation queue and result processing - Correct WebPlatformService dbGetOneRow implementation to use imported databaseService - Add proper type annotations for database operation results This commit improves type safety and fixes several TypeScript errors that were preventing proper type checking in the database service layer.
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import {
|
|
ImageResult,
|
|
PlatformService,
|
|
PlatformCapabilities,
|
|
} from "../PlatformService";
|
|
import { logger } from "../../utils/logger";
|
|
import { QueryExecResult } from "@/interfaces/database";
|
|
import databaseService from "../AbsurdSqlDatabaseService";
|
|
|
|
/**
|
|
* Platform service implementation for web browser platform.
|
|
* Implements the PlatformService interface with web-specific functionality.
|
|
*
|
|
* @remarks
|
|
* This service provides web-based implementations for:
|
|
* - Image capture using the browser's file input
|
|
* - Image selection from local filesystem
|
|
* - Image processing and conversion
|
|
*
|
|
* Note: File system operations are not available in the web platform
|
|
* due to browser security restrictions. These methods throw appropriate errors.
|
|
*/
|
|
export class WebPlatformService implements PlatformService {
|
|
/**
|
|
* Gets the capabilities of the web platform
|
|
* @returns Platform capabilities object
|
|
*/
|
|
getCapabilities(): PlatformCapabilities {
|
|
return {
|
|
hasFileSystem: false,
|
|
hasCamera: true, // Through file input with capture
|
|
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
|
|
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
|
hasFileDownload: true,
|
|
needsFileHandlingInstructions: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Not supported in web platform.
|
|
* @param _path - Unused path parameter
|
|
* @throws Error indicating file system access is not available
|
|
*/
|
|
async readFile(_path: string): Promise<string> {
|
|
throw new Error("File system access not available in web platform");
|
|
}
|
|
|
|
/**
|
|
* Not supported in web platform.
|
|
* @param _path - Unused path parameter
|
|
* @param _content - Unused content parameter
|
|
* @throws Error indicating file system access is not available
|
|
*/
|
|
async writeFile(_path: string, _content: string): Promise<void> {
|
|
throw new Error("File system access not available in web platform");
|
|
}
|
|
|
|
/**
|
|
* Not supported in web platform.
|
|
* @param _path - Unused path parameter
|
|
* @throws Error indicating file system access is not available
|
|
*/
|
|
async deleteFile(_path: string): Promise<void> {
|
|
throw new Error("File system access not available in web platform");
|
|
}
|
|
|
|
/**
|
|
* Not supported in web platform.
|
|
* @param _directory - Unused directory parameter
|
|
* @throws Error indicating file system access is not available
|
|
*/
|
|
async listFiles(_directory: string): Promise<string[]> {
|
|
throw new Error("File system access not available in web platform");
|
|
}
|
|
|
|
/**
|
|
* Opens the device camera for photo capture on desktop browsers using getUserMedia.
|
|
* On mobile browsers, uses file input with capture attribute.
|
|
* Falls back to file input if getUserMedia is not available or fails.
|
|
*
|
|
* @returns Promise resolving to the captured image data
|
|
*/
|
|
async takePicture(): Promise<ImageResult> {
|
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
|
const hasGetUserMedia = !!(
|
|
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
|
);
|
|
|
|
// If on mobile, use file input with capture attribute (existing behavior)
|
|
if (isMobile || !hasGetUserMedia) {
|
|
return new Promise((resolve, reject) => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
input.capture = "environment";
|
|
|
|
input.onchange = async (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (file) {
|
|
try {
|
|
const blob = await this.processImageFile(file);
|
|
resolve({
|
|
blob,
|
|
fileName: file.name || "photo.jpg",
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error processing camera image:", error);
|
|
reject(new Error("Failed to process camera image"));
|
|
}
|
|
} else {
|
|
reject(new Error("No image captured"));
|
|
}
|
|
};
|
|
|
|
input.click();
|
|
});
|
|
}
|
|
|
|
// Desktop: Use getUserMedia for webcam capture
|
|
return new Promise((resolve, reject) => {
|
|
let stream: MediaStream | null = null;
|
|
let video: HTMLVideoElement | null = null;
|
|
let captureButton: HTMLButtonElement | null = null;
|
|
let overlay: HTMLDivElement | null = null;
|
|
const cleanup = () => {
|
|
if (stream) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
}
|
|
if (video && video.parentNode) video.parentNode.removeChild(video);
|
|
if (captureButton && captureButton.parentNode)
|
|
captureButton.parentNode.removeChild(captureButton);
|
|
if (overlay && overlay.parentNode)
|
|
overlay.parentNode.removeChild(overlay);
|
|
};
|
|
|
|
// Move async operations inside Promise body
|
|
navigator.mediaDevices
|
|
.getUserMedia({
|
|
video: { facingMode: "user" },
|
|
})
|
|
.then((mediaStream) => {
|
|
stream = mediaStream;
|
|
// Create overlay for video and button
|
|
overlay = document.createElement("div");
|
|
overlay.style.position = "fixed";
|
|
overlay.style.top = "0";
|
|
overlay.style.left = "0";
|
|
overlay.style.width = "100vw";
|
|
overlay.style.height = "100vh";
|
|
overlay.style.background = "rgba(0,0,0,0.8)";
|
|
overlay.style.display = "flex";
|
|
overlay.style.flexDirection = "column";
|
|
overlay.style.justifyContent = "center";
|
|
overlay.style.alignItems = "center";
|
|
overlay.style.zIndex = "9999";
|
|
|
|
video = document.createElement("video");
|
|
video.autoplay = true;
|
|
video.playsInline = true;
|
|
video.style.maxWidth = "90vw";
|
|
video.style.maxHeight = "70vh";
|
|
video.srcObject = stream;
|
|
overlay.appendChild(video);
|
|
|
|
captureButton = document.createElement("button");
|
|
captureButton.textContent = "Capture Photo";
|
|
captureButton.style.marginTop = "2rem";
|
|
captureButton.style.padding = "1rem 2rem";
|
|
captureButton.style.fontSize = "1.2rem";
|
|
captureButton.style.background = "#2563eb";
|
|
captureButton.style.color = "white";
|
|
captureButton.style.border = "none";
|
|
captureButton.style.borderRadius = "0.5rem";
|
|
captureButton.style.cursor = "pointer";
|
|
overlay.appendChild(captureButton);
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
captureButton.onclick = () => {
|
|
try {
|
|
// Create a canvas to capture the frame
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = video!.videoWidth;
|
|
canvas.height = video!.videoHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.drawImage(video!, 0, 0, canvas.width, canvas.height);
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
cleanup();
|
|
if (blob) {
|
|
resolve({
|
|
blob,
|
|
fileName: `photo_${Date.now()}.jpg`,
|
|
});
|
|
} else {
|
|
reject(new Error("Failed to capture image from webcam"));
|
|
}
|
|
},
|
|
"image/jpeg",
|
|
0.95,
|
|
);
|
|
} catch (err) {
|
|
cleanup();
|
|
reject(err);
|
|
}
|
|
};
|
|
})
|
|
.catch((error) => {
|
|
cleanup();
|
|
logger.error("Error accessing webcam:", error);
|
|
// Fallback to file input
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
input.onchange = (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (file) {
|
|
this.processImageFile(file)
|
|
.then((blob) => {
|
|
resolve({
|
|
blob,
|
|
fileName: file.name || "photo.jpg",
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
logger.error("Error processing fallback image:", error);
|
|
reject(new Error("Failed to process fallback image"));
|
|
});
|
|
} else {
|
|
reject(new Error("No image selected"));
|
|
}
|
|
};
|
|
input.click();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Opens a file input dialog for selecting an image file.
|
|
* Creates a temporary file input element to access local files.
|
|
*
|
|
* @returns Promise resolving to the selected image data
|
|
* @throws Error if image processing fails or no image is selected
|
|
*
|
|
* @remarks
|
|
* Allows selection of any image file type.
|
|
* Processes the selected image to ensure consistent format.
|
|
*/
|
|
async pickImage(): Promise<ImageResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
|
|
input.onchange = async (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (file) {
|
|
try {
|
|
const blob = await this.processImageFile(file);
|
|
resolve({
|
|
blob,
|
|
fileName: file.name || "photo.jpg",
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error processing picked image:", error);
|
|
reject(new Error("Failed to process picked image"));
|
|
}
|
|
} else {
|
|
reject(new Error("No image selected"));
|
|
}
|
|
};
|
|
|
|
input.click();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Processes an image file to ensure consistent format.
|
|
* Converts the file to a data URL and then to a Blob.
|
|
*
|
|
* @param file - The image File object to process
|
|
* @returns Promise resolving to processed image Blob
|
|
* @throws Error if file reading or conversion fails
|
|
*
|
|
* @remarks
|
|
* This method ensures consistent image format across different
|
|
* input sources by converting through data URL to Blob.
|
|
*/
|
|
private async processImageFile(file: File): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
const dataUrl = event.target?.result as string;
|
|
// Convert to blob to ensure consistent format
|
|
fetch(dataUrl)
|
|
.then((res) => res.blob())
|
|
.then((blob) => resolve(blob))
|
|
.catch((error) => {
|
|
logger.error("Error converting data URL to blob:", error);
|
|
reject(error);
|
|
});
|
|
};
|
|
reader.onerror = (error) => {
|
|
logger.error("Error reading file:", error);
|
|
reject(error);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Checks if running on Capacitor platform.
|
|
* @returns false, as this is not Capacitor
|
|
*/
|
|
isCapacitor(): boolean {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks if running on Electron platform.
|
|
* @returns false, as this is not Electron
|
|
*/
|
|
isElectron(): boolean {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks if running on PyWebView platform.
|
|
* @returns false, as this is not PyWebView
|
|
*/
|
|
isPyWebView(): boolean {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks if running on web platform.
|
|
* @returns true, as this is the web implementation
|
|
*/
|
|
isWeb(): boolean {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handles deep link URLs in the web platform.
|
|
* Deep links are handled through URL parameters in the web environment.
|
|
*
|
|
* @param _url - The deep link URL to handle (unused in web implementation)
|
|
* @returns Promise that resolves immediately as web handles URLs naturally
|
|
*/
|
|
async handleDeepLink(_url: string): Promise<void> {
|
|
// Web platform can handle deep links through URL parameters
|
|
return Promise.resolve();
|
|
}
|
|
|
|
/**
|
|
* Not supported in web platform.
|
|
* @param _fileName - Unused fileName parameter
|
|
* @param _content - Unused content parameter
|
|
* @throws Error indicating file system access is not available
|
|
*/
|
|
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
|
|
throw new Error("File system access not available in web platform");
|
|
}
|
|
|
|
/**
|
|
* @see PlatformService.dbQuery
|
|
*/
|
|
dbQuery(
|
|
sql: string,
|
|
params?: unknown[],
|
|
): Promise<QueryExecResult | undefined> {
|
|
return databaseService.query(sql, params).then((result) => result[0]);
|
|
}
|
|
|
|
/**
|
|
* @see PlatformService.dbExec
|
|
*/
|
|
dbExec(
|
|
sql: string,
|
|
params?: unknown[],
|
|
): Promise<{ changes: number; lastId?: number }> {
|
|
return databaseService.run(sql, params);
|
|
}
|
|
|
|
async dbGetOneRow(sql: string, params?: unknown[]): Promise<unknown[] | undefined> {
|
|
return databaseService.query(sql, params).then((result: QueryExecResult[]) => result[0]?.values[0]);
|
|
}
|
|
}
|