chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
|
||||
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
|
||||
|
||||
const logger = createLogger('ProfilePictureUpload')
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp']
|
||||
|
||||
interface UseProfilePictureUploadProps {
|
||||
onUpload?: (url: string | null) => void
|
||||
onError?: (error: string) => void
|
||||
currentImage?: string | null
|
||||
context?: 'profile-pictures' | 'workspace-logos'
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling profile picture upload functionality.
|
||||
* Manages file validation, preview generation, and server upload.
|
||||
*/
|
||||
export function useProfilePictureUpload({
|
||||
onUpload,
|
||||
onError,
|
||||
currentImage,
|
||||
context = 'profile-pictures',
|
||||
workspaceId,
|
||||
}: UseProfilePictureUploadProps = {}) {
|
||||
const previewRef = useRef<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const onUploadRef = useRef(onUpload)
|
||||
const onErrorRef = useRef(onError)
|
||||
const currentImageRef = useRef(currentImage)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(currentImage || null)
|
||||
const [fileName, setFileName] = useState<string | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
onUploadRef.current = onUpload
|
||||
onErrorRef.current = onError
|
||||
currentImageRef.current = currentImage
|
||||
}, [onUpload, onError, currentImage])
|
||||
|
||||
useEffect(() => {
|
||||
if (previewRef.current && previewRef.current !== currentImage) {
|
||||
URL.revokeObjectURL(previewRef.current)
|
||||
previewRef.current = null
|
||||
}
|
||||
setPreviewUrl(currentImage || null)
|
||||
}, [currentImage])
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return `File "${file.name}" is too large. Maximum size is 5MB.`
|
||||
}
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
return `File "${file.name}" is not a supported image format. Please use PNG, JPEG, SVG, or WebP.`
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
|
||||
const handleThumbnailClick = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const uploadFileToServer = useCallback(
|
||||
async (file: File): Promise<string> => {
|
||||
const presignedEndpoint =
|
||||
context === 'workspace-logos' && workspaceId
|
||||
? `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(workspaceId)}`
|
||||
: `/api/files/presigned?type=${context}`
|
||||
|
||||
try {
|
||||
const result = await runUploadStrategy({
|
||||
file,
|
||||
workspaceId: workspaceId ?? '',
|
||||
context,
|
||||
presignedEndpoint,
|
||||
})
|
||||
logger.info(`${context} uploaded successfully: ${result.path}`)
|
||||
return result.path
|
||||
} catch (error) {
|
||||
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
|
||||
const { path } = await uploadViaApiFallback(file, context, workspaceId)
|
||||
logger.info(`${context} uploaded successfully via API fallback: ${path}`)
|
||||
return path
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[context, workspaceId]
|
||||
)
|
||||
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
onErrorRef.current?.(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setFileName(file.name)
|
||||
|
||||
const newPreviewUrl = URL.createObjectURL(file)
|
||||
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
|
||||
setPreviewUrl(newPreviewUrl)
|
||||
previewRef.current = newPreviewUrl
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const serverUrl = await uploadFileToServer(file)
|
||||
URL.revokeObjectURL(newPreviewUrl)
|
||||
previewRef.current = null
|
||||
setPreviewUrl(serverUrl)
|
||||
onUploadRef.current?.(serverUrl)
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to upload profile picture')
|
||||
onErrorRef.current?.(errorMessage)
|
||||
URL.revokeObjectURL(newPreviewUrl)
|
||||
previewRef.current = null
|
||||
setPreviewUrl(currentImageRef.current || null)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[uploadFileToServer, validateFile]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) processFile(file)
|
||||
},
|
||||
[processFile]
|
||||
)
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current)
|
||||
previewRef.current = null
|
||||
}
|
||||
setPreviewUrl(null)
|
||||
setFileName(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
onUploadRef.current?.(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Restore the preview to the stored image (`currentImage`) — for a form discard
|
||||
* that should revert an unsaved pick/removal without clearing the saved image.
|
||||
* Unlike {@link handleRemove}, this does not emit `onUpload(null)`.
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current)
|
||||
previewRef.current = null
|
||||
}
|
||||
setPreviewUrl(currentImageRef.current || null)
|
||||
setFileName(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewRef.current) {
|
||||
URL.revokeObjectURL(previewRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
previewUrl,
|
||||
fileName,
|
||||
fileInputRef,
|
||||
handleThumbnailClick,
|
||||
handleFileChange,
|
||||
handleFileDrop,
|
||||
handleRemove,
|
||||
reset,
|
||||
isUploading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
|
||||
|
||||
/**
|
||||
* Registers a single `beforeunload` guard for the whole settings surface,
|
||||
* active only while some section reports unsaved changes via
|
||||
* `useSettingsDirtyStore`. Mounted once in the settings shell so individual
|
||||
* pages never register their own.
|
||||
*/
|
||||
export function useSettingsBeforeUnload() {
|
||||
const isDirty = useSettingsDirtyStore((s) => s.isDirty)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [isDirty])
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
|
||||
|
||||
interface UseSettingsUnsavedGuardParams {
|
||||
isDirty: boolean
|
||||
}
|
||||
|
||||
interface SettingsUnsavedGuard {
|
||||
showUnsavedModal: boolean
|
||||
setShowUnsavedModal: (open: boolean) => void
|
||||
/** Run `onLeave` immediately when clean; when dirty, open the confirm modal and defer it. */
|
||||
guardBack: (onLeave: () => void) => void
|
||||
/** Confirmed discard — close the modal and run the deferred leave action. */
|
||||
confirmDiscard: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires a settings surface's local dirty state into the shared
|
||||
* `useSettingsDirtyStore`, so the sidebar's leave confirmation (section switch,
|
||||
* Back, workspace switch) and the centralized `beforeunload` both apply without
|
||||
* per-page wiring. Also provides an in-view back/close guard (`guardBack` + the
|
||||
* shared `UnsavedChangesModal`) for detail sub-views whose "back" is an
|
||||
* in-component state change rather than a route navigation.
|
||||
*/
|
||||
export function useSettingsUnsavedGuard({
|
||||
isDirty,
|
||||
}: UseSettingsUnsavedGuardParams): SettingsUnsavedGuard {
|
||||
const setDirty = useSettingsDirtyStore((s) => s.setDirty)
|
||||
const reset = useSettingsDirtyStore((s) => s.reset)
|
||||
const isDirtyRef = useRef(isDirty)
|
||||
const pendingLeaveRef = useRef<(() => void) | null>(null)
|
||||
const [showUnsavedModal, setShowUnsavedModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
isDirtyRef.current = isDirty
|
||||
setDirty(isDirty)
|
||||
if (!isDirty) {
|
||||
pendingLeaveRef.current = null
|
||||
setShowUnsavedModal(false)
|
||||
}
|
||||
}, [isDirty, setDirty])
|
||||
|
||||
useEffect(() => {
|
||||
return () => reset()
|
||||
}, [reset])
|
||||
|
||||
const guardBack = useCallback((onLeave: () => void) => {
|
||||
if (isDirtyRef.current) {
|
||||
pendingLeaveRef.current = onLeave
|
||||
setShowUnsavedModal(true)
|
||||
} else {
|
||||
onLeave()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const confirmDiscard = useCallback(() => {
|
||||
setShowUnsavedModal(false)
|
||||
pendingLeaveRef.current?.()
|
||||
pendingLeaveRef.current = null
|
||||
}, [])
|
||||
|
||||
return { showUnsavedModal, setShowUnsavedModal, guardBack, confirmDiscard }
|
||||
}
|
||||
Reference in New Issue
Block a user