import type { Ref } from 'vue' import { Camera, CameraResultType, CameraSource } from '@capacitor/camera' import { Filesystem } from '@capacitor/filesystem' import mime from 'mime' import { toast } from 'vue-sonner' import { useMainStore } from '~/stores/main' import { useOrganizationStore } from '~/stores/organization' import { useConsole } from './console' import { createSignedImageUrl } from './storage' const capgo = useConsole() const SIGNED_IMAGE_STORAGE_PATH_REGEX = /\/storage\/v1\/object\/(?:public\/|sign\/)?images\/(.+)$/ const LEADING_SLASHES_REGEX = /^\/+/ const IMAGES_PREFIX_REGEX = /^images\// /** Receives the error message to show when a photo upload fails. */ export type PhotoErrorHandler = (message: string) => void /** * Either a callback that displays the error, or a legacy form id (ignored): the error is then shown as a toast. */ export type PhotoErrorTarget = string | PhotoErrorHandler function reportPhotoError(target: PhotoErrorTarget, message: string) { if (typeof target === 'function') { target(message) return } if (message) toast.error(message) } function normalizeImageStoragePath(path?: string | null) { if (!path) return '' const pathWithoutQuery = path.split('?')[0] const signedUrlMatch = SIGNED_IMAGE_STORAGE_PATH_REGEX.exec(pathWithoutQuery) if (signedUrlMatch?.[1]) return signedUrlMatch[1].replace(LEADING_SLASHES_REGEX, '') return pathWithoutQuery.replace(IMAGES_PREFIX_REGEX, '').replace(LEADING_SLASHES_REGEX, '') } function getPhotoErrorMessage(error: unknown) { if (typeof error === 'string') return error if (error instanceof Error) return error.message if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message return '' } export function isPhotoSelectionCancelledError(error: unknown) { const message = getPhotoErrorMessage(error).toLowerCase() if (!message) return false return message.includes('user') && /cancel(?:led|ed)/.test(message) && /photos?|images?|camera|picker|selection|picking|app/.test(message) } function base64ToArrayBuffer(base64: string) { const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.codePointAt(i) ?? 0 } return bytes.buffer } async function uploadPhotoShared( data: string, storagePath: string, contentType: string, isLoading: Ref, callback: (success: boolean, storagePath: string, signedUrl: string) => Promise, ) { const { error } = await capgo.storage .from('images') .upload(storagePath, base64ToArrayBuffer(data), { contentType, }) const signedUrl = error ? '' : await createSignedImageUrl(storagePath) isLoading.value = false if (error || !signedUrl) await callback(false, '', '') else await callback(true, storagePath, signedUrl) } async function uploadPhotoUser(onError: PhotoErrorTarget, data: string, fileName: string, contentType: string, isLoading: Ref, wentWrong: string) { const main = useMainStore() const userId = main.user?.id if (!userId) { reportPhotoError(onError, wentWrong) console.error('No user id', userId) return } const safeUserId = userId async function userCallback(success: boolean, storagePath: string, signedUrl: string) { if (!success) { reportPhotoError(onError, wentWrong) return } let previousImagePath = '' const { data: currentUser, error: currentUserError } = await capgo .from('users') .select('image_url') .eq('id', safeUserId) .maybeSingle() if (currentUserError) console.error('cannot fetch current user image before update', currentUserError) else previousImagePath = normalizeImageStoragePath(currentUser?.image_url) const { data: usr, error: dbError } = await capgo .from('users') .update({ image_url: storagePath }) .eq('id', safeUserId) .select() .single() if (!usr || dbError) { reportPhotoError(onError, wentWrong) console.error('upload error', dbError) const { error: cleanupUploadError } = await capgo .storage .from('images') .remove([storagePath]) if (cleanupUploadError) console.error('cannot cleanup newly uploaded user image after db error', cleanupUploadError) return } if (previousImagePath && previousImagePath !== storagePath) { const { error: deletePreviousImageError } = await capgo .storage .from('images') .remove([previousImagePath]) if (deletePreviousImageError) console.error('cannot delete previous user image', deletePreviousImageError) } usr.image_url = signedUrl main.user = usr } await uploadPhotoShared(data, `${safeUserId}/${fileName}`, contentType, isLoading, userCallback) } async function uploadPhotoOrg(onError: PhotoErrorTarget, data: string, fileName: string, contentType: string, isLoading: Ref, wentWrong: string) { const organizationStore = useOrganizationStore() const gid = organizationStore.currentOrganization?.gid if (!gid) { console.error('No current org id', gid) reportPhotoError(onError, wentWrong) return } const safeGid = gid async function orgCallback(success: boolean, storagePath: string, _signedUrl: string) { if (!success) { reportPhotoError(onError, wentWrong) return } const { data: usr, error: dbError } = await capgo .from('orgs') .update({ logo: storagePath }) .eq('id', safeGid) .select('id') .single() if (!usr || dbError) { reportPhotoError(onError, wentWrong) console.error('upload error', dbError) return } await organizationStore.fetchOrganizations() organizationStore.setCurrentOrganization(usr.id) } await uploadPhotoShared(data, `org/${safeGid}/logo/${fileName}`, contentType, isLoading, orgCallback) } export async function uploadOrgLogoFile(orgId: string, file: Blob, fileName?: string) { const organizationStore = useOrganizationStore() const safeOrgId = orgId.trim() if (!safeOrgId) throw new Error('Organization ID is required') const extension = mime.getExtension(file.type) ?? 'png' const targetFileName = fileName ?? `${Date.now()}.${extension}` const storagePath = `org/${safeOrgId}/logo/${targetFileName}` const { error: uploadError } = await capgo.storage .from('images') .upload(storagePath, file, { contentType: file.type || undefined, upsert: true, }) if (uploadError) throw uploadError const { data: updatedOrg, error: updateError } = await capgo .from('orgs') .update({ logo: storagePath }) .eq('id', safeOrgId) .select('id') .single() if (updateError || !updatedOrg) { const { error: cleanupError } = await capgo.storage .from('images') .remove([storagePath]) if (cleanupError) console.error('cannot cleanup orphaned org logo upload', cleanupError) throw updateError ?? new Error('Organization logo update affected no rows') } try { await organizationStore.fetchOrganizations() } catch (error) { console.error('Failed to refresh organizations after org logo upload', error) } organizationStore.setCurrentOrganization(safeOrgId) return storagePath } function blobToData(blob: Blob) { return new Promise((resolve) => { const reader = new FileReader() reader.onloadend = () => resolve(reader.result as string) reader.readAsDataURL(blob) }) } export async function takePhoto(onError: PhotoErrorTarget, isLoading: Ref, type: 'org' | 'user', wentWrong: string) { const uploadPhoto = (type === 'user') ? uploadPhotoUser : uploadPhotoOrg let cameraPhoto try { cameraPhoto = await Camera.getPhoto({ resultType: CameraResultType.DataUrl, source: CameraSource.Camera, quality: 100, }) } catch (error) { if (!isPhotoSelectionCancelledError(error)) console.error(error) return } if (!cameraPhoto.dataUrl) return const contentType = mime.getType(cameraPhoto.format) if (!contentType) return isLoading.value = true const fileName = `${Date.now()}.${cameraPhoto.format}` try { await uploadPhoto(onError, cameraPhoto.dataUrl.split('base64,')[1], fileName, contentType, isLoading, wentWrong) } catch (e) { console.error(e) isLoading.value = false } } export async function pickPhoto(onError: PhotoErrorTarget, isLoading: Ref, type: 'org' | 'user', wentWrong: string) { const uploadPhoto = (type === 'user') ? uploadPhotoUser : uploadPhotoOrg let pickedImages try { pickedImages = await Camera.pickImages({ limit: 1, quality: 100, }) } catch (error) { if (!isPhotoSelectionCancelledError(error)) console.error(error) return } const { photos } = pickedImages if (photos.length === 0) return try { let contents if (photos[0].path) { contents = await Filesystem.readFile({ path: photos[0].path ?? photos[0].webPath, }) } else { const blob = await blobToData(await fetch(photos[0].webPath).then(r => r.blob())) contents = { data: blob.split('base64,')[1] } } const contentType = mime.getType(photos[0].format) if (!contentType) return isLoading.value = true await uploadPhoto( onError, contents.data as any, `${Date.now()}.${photos[0].format}`, contentType, isLoading, wentWrong, ) } catch (e) { console.error(e) isLoading.value = false } }