feat: add profile and storage settings components, integrate rich text editor, and implement image compression for uploads

This commit is contained in:
Mo Tarbin
2025-05-27 02:18:59 -04:00
parent 81d3144da2
commit 6ddb8d01ec
14 changed files with 710 additions and 59 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 }

View 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
})
}

View File

@@ -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>

View File

@@ -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}

View 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

View File

@@ -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>
) )
} }

View 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

View File

@@ -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'

View File

@@ -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'

View File

@@ -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 && (

View 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;
}

View 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