Files
donetick/src/views/Settings/ProfileSettings.jsx
Mo Tarbin 8c9bf0db1d feat: enhance localization across settings views
- Updated LocalizationSettings to use translation keys for titles and descriptions.
- Refactored MFASettings to utilize translation for error messages, success messages, and UI text.
- Improved NotificationSetting by integrating translation for all user-facing strings.
- Initial Hebrew translation ( testing AI translation ( some of crowdin)
- Adjusted ProfileSettings to use translation for the edit photo title.
- Enhanced SettingsOverview to utilize translation for the early access label.
- Updated SidepanelSettings to implement translation for card names and descriptions.
- Refined StorageSettings to use translation for all storage-related messages and titles.
- Improved ThemeSettings to utilize translation for theme descriptions and titles.
2026-08-11 02:19:40 -04:00

288 lines
8.7 KiB
JavaScript

import {
Autocomplete,
Avatar,
Box,
Button,
Card,
Input,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import imageCompression from 'browser-image-compression'
import { useRef, useState } from 'react'
import Cropper from 'react-easy-crop'
import { useTranslation } from 'react-i18next'
import AppModal from '../../components/common/AppModal'
import ModalActions from '../../components/common/ModalActions'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { UpdateUserDetails } from '../../utils/Fetcher'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getCroppedImg } from '../../utils/imageCropUtils'
import SettingsLayout from './SettingsLayout'
const ProfileSettings = () => {
const { t } = useTranslation('settings')
const queryClient = useQueryClient()
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const { showSuccess, showError } = useNotification()
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
const [timezone, setTimezone] = useState(
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
)
const [isUploading, setIsUploading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const fileInputRef = useRef()
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
const [showCropper, setShowCropper] = useState(false)
const [selectedFile, setSelectedFile] = useState(null)
// Get available timezones
const timezones = Intl.supportedValuesOf('timeZone')
const onCropComplete = (croppedArea, croppedAreaPixels) => {
setCroppedAreaPixels(croppedAreaPixels)
}
const handlePhotoChange = e => {
const file = e.target.files[0]
if (!file) return
setSelectedFile(URL.createObjectURL(file))
setShowCropper(true)
}
const handleCropSave = async () => {
setIsUploading(true)
try {
const croppedBlob = await getCroppedImg(
selectedFile,
croppedAreaPixels,
160,
160,
'image/jpeg',
)
// Compress the cropped image
const compressionOptions = {
maxSizeMB: 0.02, // Smaller size for profile images
maxWidthOrHeight: 160, // Match the cropped dimensions
useWebWorker: true,
fileType: 'image/jpeg',
initialQuality: 0.8,
}
const compressedFile = await imageCompression(
croppedBlob,
compressionOptions,
)
console.log(`Original size: ${(croppedBlob.size / 1024).toFixed(2)} KB`)
console.log(
`Compressed size: ${(compressedFile.size / 1024).toFixed(2)} KB`,
)
const formData = new FormData()
formData.append('file', compressedFile, 'profile.jpg')
const response = await apiClient.upload('/users/profile_photo', formData)
if (!response.ok) throw new Error('Upload failed')
await response.json()
refetchUserProfile() // Refresh user profile to get the new photoURL
showSuccess({
title: t('profile.photoUpdated'),
message: t('profile.photoUpdatedMessage'),
})
} catch (err) {
showError({
title: t('profile.uploadFailed'),
message: t('profile.uploadFailedMessage'),
})
} finally {
setIsUploading(false)
setShowCropper(false)
setSelectedFile(null)
}
}
const handleSave = async () => {
setIsSaving(true)
try {
const userDetails = { displayName, timezone }
const response = await UpdateUserDetails(userDetails)
// invalidate user profile cache here if using react-query or similar:
queryClient.invalidateQueries(['userProfile'])
queryClient.refetchQueries(['userProfile'])
if (response.ok) {
showSuccess({
title: t('profile.profileUpdated'),
message: t('profile.profileUpdatedMessage'),
})
} else {
throw new Error('Failed to update profile')
}
} catch (err) {
console.log(err)
showError({
title: t('profile.updateFailed'),
message: t('profile.updateFailedMessage'),
})
} finally {
setIsSaving(false)
}
}
// Helper to resolve photoURL with baseURL if needed
return (
<SettingsLayout title={t('profile.title')}>
<div className='grid gap-4 py-4' id='profile'>
<Typography level='body-md'>{t('profile.description')}</Typography>
<Card
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
p: 2,
maxWidth: 400,
}}
>
<Avatar
src={resolvePhotoURL(userProfile?.image)}
sx={{ width: 64, height: 64 }}
/>
<Box sx={{ flex: 1 }}>
<Button
variant='soft'
color='primary'
onClick={() => fileInputRef.current.click()}
loading={isUploading}
sx={{ mb: 1 }}
>
{t('profile.changePhoto')}
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handlePhotoChange}
/>
</Box>
</Card>
<AppModal
open={showCropper}
onClose={() => {
setShowCropper(false)
setSelectedFile(null)
}}
title={t('profile.editPhoto')}
size='sm'
closeOnBackdrop={!isUploading}
closeOnEscape={!isUploading}
footer={
<ModalActions
secondary={{
label: t('profile.cancel'),
disabled: isUploading,
onClick: () => {
setShowCropper(false)
setSelectedFile(null)
},
}}
primary={{
label: t('profile.save'),
loading: isUploading,
onClick: handleCropSave,
}}
/>
}
>
<Box
sx={{
width: '100%',
maxWidth: 320,
aspectRatio: '1',
position: 'relative',
mx: 'auto',
}}
>
<Cropper
image={selectedFile}
crop={crop}
zoom={zoom}
aspect={1}
cropShape='round'
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</Box>
</AppModal>
<Box sx={{ maxWidth: 400, mt: 3 }}>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
{t('profile.displayName')}
</Typography>
<Input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder={t('profile.displayNamePlaceholder')}
sx={{ mb: 2 }}
/>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
{t('profile.timezone')}
</Typography>
<Autocomplete
value={timezone}
onChange={(e, newValue) => setTimezone(newValue)}
options={timezones}
getOptionLabel={tz => {
const formattedTimezone = tz.replace(/_/g, ' ')
const currentTime = new Date().toLocaleString('en-US', {
timeZone: tz,
timeStyle: 'short',
})
return `${formattedTimezone} (${currentTime})`
}}
filterOptions={(options, { inputValue }) => {
if (!inputValue) return options
const searchTerms = inputValue.toLowerCase().split(/\s+/)
return options.filter(tz => {
const timezoneLower = tz.toLowerCase()
const timezoneParts = tz.toLowerCase().split(/[/_]/)
return searchTerms.every(
term =>
timezoneLower.includes(term) ||
timezoneParts.some(part => part.includes(term)),
)
})
}}
placeholder={t('profile.timezonePlaceholder')}
sx={{ mb: 2 }}
/>
<Button
variant='soft'
color='primary'
onClick={handleSave}
loading={isSaving}
sx={{ width: 120 }}
>
{t('profile.save')}
</Button>
</Box>
</div>
</SettingsLayout>
)
}
export default ProfileSettings