feat: add profile and storage settings components, integrate rich text editor, and implement image compression for uploads
This commit is contained in:
@@ -44,6 +44,7 @@
|
|||||||
"@openreplay/tracker": "^14.0.4",
|
"@openreplay/tracker": "^14.0.4",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"aos": "^2.3.4",
|
"aos": "^2.3.4",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"capacitor-plugin-safe-area": "^4.0.0",
|
"capacitor-plugin-safe-area": "^4.0.0",
|
||||||
"chrono-node": "^2.7.7",
|
"chrono-node": "^2.7.7",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
@@ -54,9 +55,12 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"murmurhash": "^2.0.1",
|
"murmurhash": "^2.0.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
"quill": "^2.0.3",
|
||||||
|
"quilljs-markdown": "^1.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-calendar": "^5.1.0",
|
"react-calendar": "^5.1.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-easy-crop": "^5.4.2",
|
||||||
"react-router-dom": "^6.21.1",
|
"react-router-dom": "^6.21.1",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"reactjs-social-login": "^2.6.3",
|
"reactjs-social-login": "^2.6.3",
|
||||||
|
|||||||
@@ -480,6 +480,13 @@ const PutWebhookURL = url => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetStorageUsage = () => {
|
||||||
|
return Fetch(`/users/storage`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: HEADERS(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AcceptCircleMemberRequest,
|
AcceptCircleMemberRequest,
|
||||||
ArchiveChore,
|
ArchiveChore,
|
||||||
@@ -509,6 +516,7 @@ export {
|
|||||||
GetLabels,
|
GetLabels,
|
||||||
GetLongLiveTokens,
|
GetLongLiveTokens,
|
||||||
GetResource,
|
GetResource,
|
||||||
|
GetStorageUsage,
|
||||||
GetSubscriptionSession,
|
GetSubscriptionSession,
|
||||||
GetThingHistory,
|
GetThingHistory,
|
||||||
GetThings,
|
GetThings,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
import { getAssetURL } from './TokenManager'
|
||||||
|
|
||||||
const isPlusAccount = userProfile => {
|
const isPlusAccount = userProfile => {
|
||||||
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
|
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,
|
Snackbar,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Textarea,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
@@ -42,11 +41,13 @@ import {
|
|||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
import Priorities from '../../utils/Priorities.jsx'
|
import Priorities from '../../utils/Priorities.jsx'
|
||||||
import LoadingComponent from '../components/Loading.jsx'
|
import LoadingComponent from '../components/Loading.jsx'
|
||||||
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
import SubTasks from '../components/SubTask.jsx'
|
import SubTasks from '../components/SubTask.jsx'
|
||||||
import { useLabels } from '../Labels/LabelQueries'
|
import { useLabels } from '../Labels/LabelQueries'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||||
import RepeatSection from './RepeatSection'
|
import RepeatSection from './RepeatSection'
|
||||||
|
|
||||||
const ASSIGN_STRATEGIES = [
|
const ASSIGN_STRATEGIES = [
|
||||||
'random',
|
'random',
|
||||||
'least_assigned',
|
'least_assigned',
|
||||||
@@ -391,10 +392,18 @@ const ChoreEdit = () => {
|
|||||||
<FormControl error={errors.description}>
|
<FormControl error={errors.description}>
|
||||||
<Typography level='h4'>Additional Details :</Typography>
|
<Typography level='h4'>Additional Details :</Typography>
|
||||||
<Typography level='h5'>What is this task about?</Typography>
|
<Typography level='h5'>What is this task about?</Typography>
|
||||||
<Textarea
|
{/* <Textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={e => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<RichTextEditor
|
||||||
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
entityId={choreId}
|
||||||
|
entityType={'chore_description'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormHelperText error>{errors.name}</FormHelperText>
|
<FormHelperText error>{errors.name}</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { Divider } from '@mui/material'
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
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 { useCircleMembers } from '../../queries/UserQueries.jsx'
|
||||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
import Priorities from '../../utils/Priorities'
|
import Priorities from '../../utils/Priorities'
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import LoadingComponent from '../components/Loading.jsx'
|
import LoadingComponent from '../components/Loading.jsx'
|
||||||
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
import SubTasks from '../components/SubTask.jsx'
|
import SubTasks from '../components/SubTask.jsx'
|
||||||
const IconCard = styled('div')({
|
const IconCard = styled('div')({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -89,7 +90,7 @@ const ChoreView = () => {
|
|||||||
data: choreData,
|
data: choreData,
|
||||||
isLoading: isChoreLoading,
|
isLoading: isChoreLoading,
|
||||||
refetch: refetchChore,
|
refetch: refetchChore,
|
||||||
} = useChore(choreId)
|
} = useChoreDetails(choreId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!choreData || !choreData.res || !circleMembersData) {
|
if (!choreData || !choreData.res || !circleMembersData) {
|
||||||
@@ -491,9 +492,7 @@ const ChoreView = () => {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography level='body-md' sx={{ mb: 1 }}>
|
<RichTextEditor value={chore.description} isEditable={false} />
|
||||||
{chore.description || '--'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
@@ -516,7 +515,15 @@ const ChoreView = () => {
|
|||||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||||
Subtasks :
|
Subtasks :
|
||||||
</Typography>
|
</Typography>
|
||||||
<Sheet variant='plain' sx={{ borderRadius: 'lg', p: 1 }}>
|
<Sheet
|
||||||
|
variant='plain'
|
||||||
|
sx={{
|
||||||
|
borderRadius: 'lg',
|
||||||
|
p: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
// maxHeight: '100px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SubTasks
|
<SubTasks
|
||||||
editMode={false}
|
editMode={false}
|
||||||
tasks={chore.subTasks}
|
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'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useContext, useEffect, useState } from 'react'
|
import { useContext, useEffect, useState } from 'react'
|
||||||
import { Navigate } from 'react-router-dom'
|
|
||||||
import { UserContext } from '../../contexts/UserContext'
|
import { UserContext } from '../../contexts/UserContext'
|
||||||
import Logo from '../../Logo'
|
import Logo from '../../Logo'
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +34,8 @@ import { isPlusAccount } from '../../utils/Helpers'
|
|||||||
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||||
import APITokenSettings from './APITokenSettings'
|
import APITokenSettings from './APITokenSettings'
|
||||||
import NotificationSetting from './NotificationSetting'
|
import NotificationSetting from './NotificationSetting'
|
||||||
|
import ProfileSettings from './ProfileSettings'
|
||||||
|
import StorageSettings from './StorageSettings'
|
||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
@@ -125,6 +126,7 @@ const Settings = () => {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
<ProfileSettings />
|
||||||
<div className='grid gap-4 py-4' id='sharing'>
|
<div className='grid gap-4 py-4' id='sharing'>
|
||||||
<Typography level='h3'>Circle settings</Typography>
|
<Typography level='h3'>Circle settings</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -490,6 +492,7 @@ const Settings = () => {
|
|||||||
</div>
|
</div>
|
||||||
<NotificationSetting />
|
<NotificationSetting />
|
||||||
<APITokenSettings />
|
<APITokenSettings />
|
||||||
|
<StorageSettings />
|
||||||
<div className='grid gap-4 py-4'>
|
<div className='grid gap-4 py-4'>
|
||||||
<Typography level='h3'>Theme preferences</Typography>
|
<Typography level='h3'>Theme preferences</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -499,46 +502,6 @@ const Settings = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</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>
|
</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 { useCircleMembers } from '../../queries/UserQueries.jsx'
|
||||||
import { ChoresGrouper } from '../../utils/Chores'
|
import { ChoresGrouper } from '../../utils/Chores'
|
||||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||||
|
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||||
import LoadingComponent from '../components/Loading'
|
import LoadingComponent from '../components/Loading'
|
||||||
|
|
||||||
const groupByDate = history => {
|
const groupByDate = history => {
|
||||||
@@ -410,10 +411,18 @@ const UserActivites = () => {
|
|||||||
renderValue={selected => (
|
renderValue={selected => (
|
||||||
<Typography
|
<Typography
|
||||||
startDecorator={
|
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)
|
circleUsers.find(user => user.userId === selectedUser)
|
||||||
?.displayName[0]
|
?.image
|
||||||
}
|
}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
@@ -427,6 +436,14 @@ const UserActivites = () => {
|
|||||||
>
|
>
|
||||||
{circleUsers.map(user => (
|
{circleUsers.map(user => (
|
||||||
<Option key={user.userId} value={user.userId}>
|
<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>
|
<Typography>{user.displayName}</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
color='success'
|
color='success'
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import LoadingComponent from '../components/Loading.jsx'
|
|||||||
import { useChoresHistory } from '../../queries/ChoreQueries.jsx'
|
import { useChoresHistory } from '../../queries/ChoreQueries.jsx'
|
||||||
import { useCircleMembers } from '../../queries/UserQueries.jsx'
|
import { useCircleMembers } from '../../queries/UserQueries.jsx'
|
||||||
import { RedeemPoints } from '../../utils/Fetcher.jsx'
|
import { RedeemPoints } from '../../utils/Fetcher.jsx'
|
||||||
|
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||||
import RedeemPointsModal from '../Modals/RedeemPointsModal'
|
import RedeemPointsModal from '../Modals/RedeemPointsModal'
|
||||||
const UserPoints = () => {
|
const UserPoints = () => {
|
||||||
const [tabValue, setTabValue] = useState(7)
|
const [tabValue, setTabValue] = useState(7)
|
||||||
@@ -258,7 +259,15 @@ const UserPoints = () => {
|
|||||||
renderValue={selected => (
|
renderValue={selected => (
|
||||||
<Typography
|
<Typography
|
||||||
startDecorator={
|
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)
|
circleUsers.find(user => user.userId === selectedUser)
|
||||||
?.displayName[0]
|
?.displayName[0]
|
||||||
@@ -275,6 +284,14 @@ const UserPoints = () => {
|
|||||||
>
|
>
|
||||||
{circleUsers.map(user => (
|
{circleUsers.map(user => (
|
||||||
<Option key={user.userId} value={user.userId}>
|
<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>
|
<Typography>{user.displayName}</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
color='success'
|
color='success'
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
ModalOverflow,
|
ModalOverflow,
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
Textarea,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { FormControl } from '@mui/material'
|
import { FormControl } from '@mui/material'
|
||||||
@@ -25,7 +24,9 @@ import { isPlusAccount } from '../../utils/Helpers'
|
|||||||
import { useLabels } from '../Labels/LabelQueries'
|
import { useLabels } from '../Labels/LabelQueries'
|
||||||
import SmartTaskTitleInput from '../TestView/SmartTaskTitleInput'
|
import SmartTaskTitleInput from '../TestView/SmartTaskTitleInput'
|
||||||
import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers'
|
import { parseLabels, parsePriority, parseRepeatV2 } from './CustomParsers'
|
||||||
|
|
||||||
import LearnMoreButton from './LearnMore'
|
import LearnMoreButton from './LearnMore'
|
||||||
|
import RichTextEditor from './RichTextEditor'
|
||||||
import SubTasks from './SubTask'
|
import SubTasks from './SubTask'
|
||||||
|
|
||||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||||
@@ -497,11 +498,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
{hasDescription && (
|
{hasDescription && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography level='body-sm'>Description:</Typography>
|
<Typography level='body-sm'>Description:</Typography>
|
||||||
<Textarea
|
<div>
|
||||||
minRows={2}
|
<RichTextEditor
|
||||||
value={description}
|
onChange={setDescription}
|
||||||
onChange={e => setDescription(e.target.value)}
|
entityType={'chore_description'}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{hasSubTasks && (
|
{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