feat: add profile and storage settings components, integrate rich text editor, and implement image compression for uploads
This commit is contained in:
@@ -480,6 +480,13 @@ const PutWebhookURL = url => {
|
||||
})
|
||||
}
|
||||
|
||||
const GetStorageUsage = () => {
|
||||
return Fetch(`/users/storage`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
AcceptCircleMemberRequest,
|
||||
ArchiveChore,
|
||||
@@ -509,6 +516,7 @@ export {
|
||||
GetLabels,
|
||||
GetLongLiveTokens,
|
||||
GetResource,
|
||||
GetStorageUsage,
|
||||
GetSubscriptionSession,
|
||||
GetThingHistory,
|
||||
GetThings,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import moment from 'moment'
|
||||
import { getAssetURL } from './TokenManager'
|
||||
|
||||
const isPlusAccount = userProfile => {
|
||||
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
|
||||
}
|
||||
|
||||
export { isPlusAccount }
|
||||
const resolvePhotoURL = url => {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http') || url.startsWith('https')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('assets')) {
|
||||
return getAssetURL(url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
export { isPlusAccount, resolvePhotoURL }
|
||||
|
||||
45
src/utils/imageCropUtils.js
Normal file
45
src/utils/imageCropUtils.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Utility to crop and resize an image to a square (e.g. 320x320) and return a JPEG Blob
|
||||
// Usage: await getCroppedImg(imageSrc, croppedAreaPixels, width, height, mimeType)
|
||||
export async function getCroppedImg(
|
||||
imageSrc,
|
||||
crop,
|
||||
width,
|
||||
height,
|
||||
mimeType = 'image/jpeg',
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new window.Image()
|
||||
image.crossOrigin = 'anonymous'
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
// Draw the cropped image to the canvas
|
||||
ctx.drawImage(
|
||||
image,
|
||||
crop.x,
|
||||
crop.y,
|
||||
crop.width,
|
||||
crop.height,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
canvas.toBlob(
|
||||
blob => {
|
||||
if (!blob) {
|
||||
reject(new Error('Canvas is empty'))
|
||||
return
|
||||
}
|
||||
resolve(blob)
|
||||
},
|
||||
mimeType,
|
||||
0.92,
|
||||
)
|
||||
}
|
||||
image.onerror = error => reject(error)
|
||||
image.src = imageSrc
|
||||
})
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Snackbar,
|
||||
Stack,
|
||||
Switch,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
@@ -42,11 +41,13 @@ import {
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import Priorities from '../../utils/Priorities.jsx'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
import RepeatSection from './RepeatSection'
|
||||
|
||||
const ASSIGN_STRATEGIES = [
|
||||
'random',
|
||||
'least_assigned',
|
||||
@@ -391,10 +392,18 @@ const ChoreEdit = () => {
|
||||
<FormControl error={errors.description}>
|
||||
<Typography level='h4'>Additional Details :</Typography>
|
||||
<Typography level='h5'>What is this task about?</Typography>
|
||||
<Textarea
|
||||
{/* <Textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/> */}
|
||||
|
||||
<RichTextEditor
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
entityId={choreId}
|
||||
entityType={'chore_description'}
|
||||
/>
|
||||
|
||||
<FormHelperText error>{errors.name}</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Divider } from '@mui/material'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useChore } from '../../queries/ChoreQueries.jsx'
|
||||
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
|
||||
import { useCircleMembers } from '../../queries/UserQueries.jsx'
|
||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
const IconCard = styled('div')({
|
||||
display: 'flex',
|
||||
@@ -89,7 +90,7 @@ const ChoreView = () => {
|
||||
data: choreData,
|
||||
isLoading: isChoreLoading,
|
||||
refetch: refetchChore,
|
||||
} = useChore(choreId)
|
||||
} = useChoreDetails(choreId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!choreData || !choreData.res || !circleMembersData) {
|
||||
@@ -491,9 +492,7 @@ const ChoreView = () => {
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-md' sx={{ mb: 1 }}>
|
||||
{chore.description || '--'}
|
||||
</Typography>
|
||||
<RichTextEditor value={chore.description} isEditable={false} />
|
||||
</Box>
|
||||
</Sheet>
|
||||
</>
|
||||
@@ -516,7 +515,15 @@ const ChoreView = () => {
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
Subtasks :
|
||||
</Typography>
|
||||
<Sheet variant='plain' sx={{ borderRadius: 'lg', p: 1 }}>
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{
|
||||
borderRadius: 'lg',
|
||||
p: 1,
|
||||
overflow: 'auto',
|
||||
// maxHeight: '100px',
|
||||
}}
|
||||
>
|
||||
<SubTasks
|
||||
editMode={false}
|
||||
tasks={chore.subTasks}
|
||||
|
||||
233
src/views/Settings/ProfileSettings.jsx
Normal file
233
src/views/Settings/ProfileSettings.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Modal from '@mui/joy/Modal'
|
||||
import ModalDialog from '@mui/joy/ModalDialog'
|
||||
import { useContext, useRef, useState } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getCroppedImg } from '../../utils/imageCropUtils'
|
||||
import { UploadFile } from '../../utils/TokenManager'
|
||||
|
||||
const ProfileSettings = () => {
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
||||
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [snackbar, setSnackbar] = useState({
|
||||
open: false,
|
||||
message: '',
|
||||
color: 'success',
|
||||
})
|
||||
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)
|
||||
|
||||
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,
|
||||
320,
|
||||
320,
|
||||
'image/jpeg',
|
||||
)
|
||||
const formData = new FormData()
|
||||
formData.append('file', croppedBlob, 'profile.jpg')
|
||||
const response = await UploadFile('/users/profile_photo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
const data = await response.json()
|
||||
const url = resolvePhotoURL(data.url || data.sign)
|
||||
|
||||
setPhotoURL(url)
|
||||
setUserProfile({ ...userProfile, image: url })
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Profile photo updated!',
|
||||
color: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to upload photo.',
|
||||
color: 'danger',
|
||||
})
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setUserProfile({ ...userProfile, displayName })
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Profile updated!',
|
||||
color: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
// Helper to resolve photoURL with baseURL if needed
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4' id='profile'>
|
||||
<Typography level='h3'>Profile Settings</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Update your display name and profile photo.
|
||||
</Typography>
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Avatar src={photoURL} sx={{ width: 64, height: 64 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='primary'
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
loading={isUploading}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Change Photo
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handlePhotoChange}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
<Modal
|
||||
open={showCropper}
|
||||
onClose={() => {
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
}}
|
||||
>
|
||||
<ModalDialog
|
||||
layout='center'
|
||||
sx={{
|
||||
width: 360,
|
||||
maxWidth: '90vw',
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: 24,
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 420,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 320, height: 320, position: 'relative', mt: 2 }}>
|
||||
<Cropper
|
||||
image={selectedFile}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape='round'
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
width: '100%',
|
||||
p: 2,
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleCropSave}
|
||||
loading={isUploading}
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='md'
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
}}
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
{/* <Box sx={{ maxWidth: 400 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||
Display Name
|
||||
</Typography>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
placeholder='Enter your display name'
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='primary'
|
||||
onClick={handleSave}
|
||||
sx={{ width: 120 }}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box> */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
color={snackbar.color}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Snackbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileSettings
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
@@ -35,6 +34,8 @@ import { isPlusAccount } from '../../utils/Helpers'
|
||||
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||
import APITokenSettings from './APITokenSettings'
|
||||
import NotificationSetting from './NotificationSetting'
|
||||
import ProfileSettings from './ProfileSettings'
|
||||
import StorageSettings from './StorageSettings'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
const Settings = () => {
|
||||
@@ -125,6 +126,7 @@ const Settings = () => {
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<ProfileSettings />
|
||||
<div className='grid gap-4 py-4' id='sharing'>
|
||||
<Typography level='h3'>Circle settings</Typography>
|
||||
<Divider />
|
||||
@@ -490,6 +492,7 @@ const Settings = () => {
|
||||
</div>
|
||||
<NotificationSetting />
|
||||
<APITokenSettings />
|
||||
<StorageSettings />
|
||||
<div className='grid gap-4 py-4'>
|
||||
<Typography level='h3'>Theme preferences</Typography>
|
||||
<Divider />
|
||||
@@ -499,46 +502,6 @@ const Settings = () => {
|
||||
</Typography>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 py-4'>
|
||||
<Typography level='h3'>Experimental Features </Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Clean up some part of the local storage and cache. Only use if you
|
||||
know are you doing.
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to clear your local storage and cache? This will remove all your data. on device and require login`,
|
||||
)
|
||||
if (confirmed) {
|
||||
localStorage.clear()
|
||||
Navigate('/login')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear Local Storage and Cache
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to clear your local storage and cache? This will remove all your data.`,
|
||||
)
|
||||
if (confirmed) {
|
||||
localStorage.removeItem('offline_cache')
|
||||
localStorage.removeItem('offline_request_queue')
|
||||
localStorage.removeItem('offlineTasks')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear Offline Cache and Offline tasks
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
96
src/views/Settings/StorageSettings.jsx
Normal file
96
src/views/Settings/StorageSettings.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Button, Card, Divider, LinearProgress, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { GetStorageUsage } from '../../utils/Fetcher'
|
||||
|
||||
const StorageSettings = () => {
|
||||
const Navigate = useNavigate()
|
||||
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
GetStorageUsage().then(resp => {
|
||||
resp.json().then(data => {
|
||||
setUsage(data.res)
|
||||
setLoading(false)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const percent =
|
||||
usage.total > 0 ? Math.round((usage.used / usage.total) * 100) : 0
|
||||
const usedMB = (usage.used / (1024 * 1024)).toFixed(2)
|
||||
const totalMB = (usage.total / (1024 * 1024)).toFixed(2)
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4' id='storage'>
|
||||
<Typography level='h3'>Storage Settings</Typography>
|
||||
<Divider />
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
Server Storage Usage
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This is the storage used by your account on our servers (e.g. files,
|
||||
images, and data you have uploaded).
|
||||
</Typography>
|
||||
{loading ? (
|
||||
<Typography level='body-xs'>Loading...</Typography>
|
||||
) : (
|
||||
<>
|
||||
<LinearProgress determinate value={percent} sx={{ mb: 1 }} />
|
||||
<Typography level='body-xs'>
|
||||
{usedMB} MB used / {totalMB} MB total ({percent}%)
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
|
||||
Cache
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This is data stored locally in your browser for faster access and
|
||||
offline use. Clearing this will not affect your server data, but may
|
||||
log you out or remove offline tasks.
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.`,
|
||||
)
|
||||
if (confirmed) {
|
||||
localStorage.clear()
|
||||
Navigate('/login')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear All Local Storage and Cache
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to clear only the offline cache and tasks?`,
|
||||
)
|
||||
if (confirmed) {
|
||||
localStorage.removeItem('offline_cache')
|
||||
localStorage.removeItem('offline_request_queue')
|
||||
localStorage.removeItem('offlineTasks')
|
||||
}
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Clear Offline Cache and Offline Tasks
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StorageSettings
|
||||
@@ -28,6 +28,7 @@ import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers } from '../../queries/UserQueries.jsx'
|
||||
import { ChoresGrouper } from '../../utils/Chores'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
const groupByDate = history => {
|
||||
@@ -410,10 +411,18 @@ const UserActivites = () => {
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Avatar color='primary' m={0} size='sm'>
|
||||
<Avatar
|
||||
color='primary'
|
||||
m={0}
|
||||
size='sm'
|
||||
src={resolvePhotoURL(
|
||||
circleUsers.find(user => user.userId === selectedUser)
|
||||
?.image,
|
||||
)}
|
||||
>
|
||||
{
|
||||
circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName[0]
|
||||
?.image
|
||||
}
|
||||
</Avatar>
|
||||
}
|
||||
@@ -427,6 +436,14 @@ const UserActivites = () => {
|
||||
>
|
||||
{circleUsers.map(user => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Avatar
|
||||
color='primary'
|
||||
m={0}
|
||||
size='sm'
|
||||
src={resolvePhotoURL(user.image)}
|
||||
>
|
||||
{user.image}
|
||||
</Avatar>
|
||||
<Typography>{user.displayName}</Typography>
|
||||
<Chip
|
||||
color='success'
|
||||
|
||||
@@ -29,6 +29,7 @@ import LoadingComponent from '../components/Loading.jsx'
|
||||
import { useChoresHistory } from '../../queries/ChoreQueries.jsx'
|
||||
import { useCircleMembers } from '../../queries/UserQueries.jsx'
|
||||
import { RedeemPoints } from '../../utils/Fetcher.jsx'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
import RedeemPointsModal from '../Modals/RedeemPointsModal'
|
||||
const UserPoints = () => {
|
||||
const [tabValue, setTabValue] = useState(7)
|
||||
@@ -258,7 +259,15 @@ const UserPoints = () => {
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Avatar color='primary' m={0} size='sm'>
|
||||
<Avatar
|
||||
color='primary'
|
||||
m={0}
|
||||
size='sm'
|
||||
src={resolvePhotoURL(
|
||||
circleUsers.find(user => user.userId === selectedUser)
|
||||
?.image,
|
||||
)}
|
||||
>
|
||||
{
|
||||
circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName[0]
|
||||
@@ -275,6 +284,14 @@ const UserPoints = () => {
|
||||
>
|
||||
{circleUsers.map(user => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Avatar
|
||||
color='primary'
|
||||
m={0}
|
||||
size='sm'
|
||||
src={resolvePhotoURL(user.image)}
|
||||
>
|
||||
{user.displayName[0]}
|
||||
</Avatar>
|
||||
<Typography>{user.displayName}</Typography>
|
||||
<Chip
|
||||
color='success'
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ModalOverflow,
|
||||
Option,
|
||||
Select,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { FormControl } from '@mui/material'
|
||||
@@ -25,7 +24,9 @@ import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import SmartTaskTitleInput from '../TestView/SmartTaskTitleInput'
|
||||
import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers'
|
||||
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import SubTasks from './SubTask'
|
||||
|
||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
@@ -497,11 +498,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
{hasDescription && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Description:</Typography>
|
||||
<Textarea
|
||||
minRows={2}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<RichTextEditor
|
||||
onChange={setDescription}
|
||||
entityType={'chore_description'}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{hasSubTasks && (
|
||||
|
||||
53
src/views/components/RichTextEditor.css
Normal file
53
src/views/components/RichTextEditor.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.editor-view-mode h1 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.editor-view-mode h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.editor-view-mode h3 {
|
||||
font-size: 1.17em;
|
||||
}
|
||||
.editor-view-mode h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
.editor-view-mode h5 {
|
||||
font-size: 0.83em;
|
||||
}
|
||||
.editor-view-mode h6 {
|
||||
font-size: 0.67em;
|
||||
}
|
||||
.editor-view-mode p {
|
||||
font-size: 1em;
|
||||
}
|
||||
.editor-view-mode ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.editor-view-mode ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.editor-view-mode li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.editor-view-mode blockquote {
|
||||
border-left: 4px solid #ccc;
|
||||
padding-left: 20px;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.editor-view-mode pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.editor-view-mode code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.editor-view-mode img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
186
src/views/components/RichTextEditor.jsx
Normal file
186
src/views/components/RichTextEditor.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import QuillMarkdown from 'quilljs-markdown'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useError } from '../../service/ErrorProvider'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { UploadFile } from '../../utils/TokenManager'
|
||||
import './RichTextEditor.css'
|
||||
|
||||
const RichTextEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
isEditable = true,
|
||||
variant = 'outlined',
|
||||
entityId,
|
||||
entityType,
|
||||
}) => {
|
||||
const { showError } = useError()
|
||||
const quillRef = useRef(null)
|
||||
const editorRef = useRef(null)
|
||||
|
||||
// Image upload handler - wrapped in useCallback to avoid recreating on every render
|
||||
const handleImageUpload = useCallback(() => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
// Define compression options based on entity type
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1, // Smaller size for profile images
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, // Smaller dimensions for profile images
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg', // Always convert to JPEG
|
||||
}
|
||||
|
||||
// Compress the image
|
||||
const compressedFile = await imageCompression(file, compressionOptions)
|
||||
|
||||
// Create new file with .jpg extension to ensure it's treated as JPEG
|
||||
const compressedJpegFile = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
|
||||
console.log(`Original size: ${(file.size / 1024 / 1024).toFixed(2)} MB`)
|
||||
console.log(
|
||||
`Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`,
|
||||
)
|
||||
|
||||
// Upload compressed image to backend
|
||||
const formData = new FormData()
|
||||
formData.append('file', compressedJpegFile)
|
||||
formData.append('entityId', entityId)
|
||||
formData.append('entityType', entityType)
|
||||
const response = await UploadFile('/assets/chore', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.status === 507) {
|
||||
showError({
|
||||
title: 'Storage Quota Exceeded',
|
||||
message: 'You have exceeded your quota for uploading files.',
|
||||
})
|
||||
return
|
||||
} else if (response.status === 413) {
|
||||
showError({
|
||||
title: 'File Too Large',
|
||||
message: 'The file you are trying to upload is too large.',
|
||||
})
|
||||
return
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload image.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const url = resolvePhotoURL(data.url || data.sign)
|
||||
// Insert image into Quill
|
||||
const quill = editorRef.current
|
||||
const range = quill.getSelection()
|
||||
quill.insertEmbed(range ? range.index : 0, 'image', url)
|
||||
} catch (error) {
|
||||
console.error('Error during image processing or upload:', error)
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'An error occurred while processing the image.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [entityId, entityType, showError]) // Dependencies for useCallback
|
||||
|
||||
useEffect(() => {
|
||||
if (!quillRef.current) return
|
||||
if (!editorRef.current && isEditable) {
|
||||
editorRef.current = new Quill(quillRef.current, {
|
||||
theme: variant === 'bubble' ? 'bubble' : 'snow',
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
[{ header: [1, 2, 3, 4, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['blockquote', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
['link', 'image'],
|
||||
['clean'],
|
||||
],
|
||||
handlers: {
|
||||
image: handleImageUpload,
|
||||
},
|
||||
},
|
||||
},
|
||||
placeholder: 'Enter description...',
|
||||
})
|
||||
new QuillMarkdown(editorRef.current, {})
|
||||
editorRef.current.root.innerHTML = value
|
||||
editorRef.current.on('text-change', () => {
|
||||
if (onChange) {
|
||||
onChange(editorRef.current.root.innerHTML)
|
||||
}
|
||||
})
|
||||
}
|
||||
// If switching to read-only mode, disable Quill instance
|
||||
if (editorRef.current && !isEditable) {
|
||||
// editorRef.current.disable()
|
||||
editorRef.current.readOnly = true
|
||||
|
||||
// If switching back to editable, enable Quill
|
||||
if (editorRef.current && isEditable) {
|
||||
// editorRef.current.enable()
|
||||
editorRef.current.readOnly = false
|
||||
}
|
||||
}
|
||||
}, [onChange, value, isEditable, variant, handleImageUpload]) // Added handleImageUpload to dependency array
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && isEditable) {
|
||||
if (editorRef.current.root.innerHTML !== value) {
|
||||
editorRef.current.root.innerHTML = value || ''
|
||||
}
|
||||
}
|
||||
}, [value, isEditable])
|
||||
|
||||
if (!isEditable) {
|
||||
// Display-only mode: render HTML
|
||||
return (
|
||||
<div
|
||||
className='editor-view-mode'
|
||||
style={{
|
||||
minHeight: 120,
|
||||
overflow: 'scroll',
|
||||
// border:
|
||||
// '1px solid var(--joy-palette-neutral-outlinedBorder, #DDE7EE)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
background: 'var(--joy-palette-background-surface, #fff)',
|
||||
color: 'var(--joy-palette-text-primary, #1A2027)',
|
||||
fontFamily:
|
||||
'var(--joy-fontFamily-body, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif)',
|
||||
fontSize: 16,
|
||||
boxShadow:
|
||||
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`quill-root quill-variant-${variant}`}>
|
||||
<div ref={quillRef} style={{ minHeight: 120 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RichTextEditor
|
||||
Reference in New Issue
Block a user