Part of #145. Sixteen files that were left out of my earlier PRs because my branch also carried unrelated changes in them. Those are stripped here: each file is your current `develop` version with the string extraction applied on top, nothing else. Covered: the chore action hook and its toasts, activities and smart-insight cards, the chore toolbar, chore history and its card, saved filters, the timer details view, project and label modals, the notification picker, the pending badge, the sync status indicator, the SSE settings and hook, and the profile avatar menu. All namespaces already exist, so `src/i18n/config.js` is untouched. Keys added: 83 `chores`, 31 `common`, 10 `timer`, 9 `history`, 6 `labels`, 5 `projects`, 2 `filters`, 1 `settings`. English only — no translations, no behaviour change. Every t() value is checked to appear character-for-character in the code it replaces, or to match the value already in your dictionary for the same key: 221 call sites, no mismatches. Five files from the same batch are deliberately left out. They build translated labels in module-level constant tables, where the hook cannot be called — `FilterBar`, `RepeatSection`, `RepeatPickerField`, `FilterBuilderContent` and `AdvancedOptionsSection`. Those need the key to travel as data and be resolved inside the component, which is a design change rather than an extraction, so it deserves its own PR.
268 lines
7.9 KiB
JavaScript
268 lines
7.9 KiB
JavaScript
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
FormControl,
|
|
FormLabel,
|
|
Input,
|
|
Stack,
|
|
Textarea,
|
|
Typography,
|
|
} from '@mui/joy'
|
|
import { useEffect, useState } from 'react'
|
|
import ModalActions from '../../../components/common/ModalActions'
|
|
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
|
import PROJECT_COLORS, {
|
|
getTextColorFromBackgroundColor,
|
|
} from '../../../utils/Colors'
|
|
import PROJECT_ICONS, { getIconComponent } from '../../../utils/ProjectIcons'
|
|
import {
|
|
useCreateProject,
|
|
useUpdateProject,
|
|
} from '../../Projects/ProjectQueries'
|
|
import IconPickerModal from './IconPickerModal'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
|
const { t } = useTranslation('projects')
|
|
const { ResponsiveModal } = useResponsiveModal()
|
|
const [projectName, setProjectName] = useState('')
|
|
const [projectDescription, setProjectDescription] = useState('')
|
|
const [projectColor, setProjectColor] = useState(PROJECT_COLORS[0].value)
|
|
const [projectIcon, setProjectIcon] = useState(PROJECT_ICONS[0].value)
|
|
const [error, setError] = useState('')
|
|
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false)
|
|
|
|
const createProjectMutation = useCreateProject()
|
|
const updateProjectMutation = useUpdateProject()
|
|
|
|
// Initialize form when modal opens or project changes
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
if (project) {
|
|
// Editing existing project
|
|
setProjectName(project.name || '')
|
|
setProjectDescription(project.description || '')
|
|
setProjectColor(project.color || PROJECT_COLORS[0].value)
|
|
setProjectIcon(project.icon || PROJECT_ICONS[0].value)
|
|
} else {
|
|
// Creating new project
|
|
setProjectName('')
|
|
setProjectDescription('')
|
|
setProjectColor(PROJECT_COLORS[0].value)
|
|
setProjectIcon(PROJECT_ICONS[0].value)
|
|
}
|
|
setError('')
|
|
}
|
|
}, [isOpen, project])
|
|
|
|
const handleSubmit = e => {
|
|
e.preventDefault()
|
|
|
|
if (!projectName.trim()) {
|
|
setError(t('modal.errorNameRequired'))
|
|
return
|
|
}
|
|
|
|
setError('')
|
|
|
|
const projectData = {
|
|
name: projectName.trim(),
|
|
description: projectDescription.trim(),
|
|
color: projectColor,
|
|
icon: projectIcon,
|
|
}
|
|
|
|
if (project) {
|
|
// Update existing project
|
|
updateProjectMutation.mutate(
|
|
{ projectId: project.id, projectData },
|
|
{
|
|
onSuccess: updatedProject => {
|
|
onSave(updatedProject)
|
|
onClose()
|
|
},
|
|
onError: error => {
|
|
console.error('Error updating project:', error)
|
|
setError(t('modal.errorUpdate'))
|
|
},
|
|
},
|
|
)
|
|
} else {
|
|
// Create new project
|
|
createProjectMutation.mutate(projectData, {
|
|
onSuccess: newProject => {
|
|
onSave(newProject)
|
|
onClose()
|
|
},
|
|
onError: error => {
|
|
console.error('Error creating project:', error)
|
|
setError(t('modal.errorCreate'))
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
const isLoading =
|
|
createProjectMutation.isPending || updateProjectMutation.isPending
|
|
if (!isLoading) {
|
|
onClose()
|
|
}
|
|
}
|
|
|
|
const isSubmitting =
|
|
createProjectMutation.isPending || updateProjectMutation.isPending
|
|
|
|
const handleIconSelect = iconValue => {
|
|
setProjectIcon(iconValue)
|
|
setIsIconPickerOpen(false)
|
|
}
|
|
|
|
return (
|
|
<ResponsiveModal
|
|
open={isOpen}
|
|
onClose={handleClose}
|
|
size='md'
|
|
unmountDelay={250}
|
|
fullWidth={true}
|
|
title={project ? 'Edit Project' : 'Create New Project'}
|
|
closeOnBackdrop={!isSubmitting}
|
|
closeOnEscape={!isSubmitting}
|
|
footer={
|
|
<ModalActions
|
|
secondary={{
|
|
label: t('common:cancel'),
|
|
onClick: handleClose,
|
|
disabled: isSubmitting,
|
|
}}
|
|
primary={{
|
|
label: project ? 'Update' : 'Create',
|
|
type: 'submit',
|
|
form: 'project-form',
|
|
loading: isSubmitting,
|
|
disabled: !projectName.trim() || isSubmitting,
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
<form onSubmit={handleSubmit} id='project-form'>
|
|
<Stack spacing={3}>
|
|
{/* Project Name */}
|
|
<FormControl required>
|
|
<FormLabel>Project Name</FormLabel>
|
|
<Input
|
|
value={projectName}
|
|
onChange={e => setProjectName(e.target.value)}
|
|
placeholder={t('modal.namePlaceholder')}
|
|
autoFocus
|
|
disabled={isSubmitting}
|
|
/>
|
|
</FormControl>
|
|
|
|
{/* Project Description */}
|
|
<FormControl>
|
|
<FormLabel>Description</FormLabel>
|
|
<Textarea
|
|
value={projectDescription}
|
|
onChange={e => setProjectDescription(e.target.value)}
|
|
placeholder={t('modal.descriptionPlaceholder')}
|
|
minRows={2}
|
|
maxRows={4}
|
|
disabled={isSubmitting}
|
|
/>
|
|
</FormControl>
|
|
|
|
{/* Icon Selection */}
|
|
<FormControl>
|
|
<FormLabel>Project Icon</FormLabel>
|
|
<Button
|
|
variant='outlined'
|
|
onClick={() => setIsIconPickerOpen(true)}
|
|
startDecorator={
|
|
<Avatar
|
|
size='sm'
|
|
sx={{
|
|
width: 24,
|
|
height: 24,
|
|
bgcolor: projectColor,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
'& svg': {
|
|
display: 'block',
|
|
margin: '0 auto',
|
|
},
|
|
}}
|
|
>
|
|
{(() => {
|
|
const IconComponent = getIconComponent(projectIcon)
|
|
return (
|
|
<IconComponent
|
|
sx={{
|
|
fontSize: 14,
|
|
color: getTextColorFromBackgroundColor(projectColor),
|
|
display: 'block',
|
|
}}
|
|
/>
|
|
)
|
|
})()}
|
|
</Avatar>
|
|
}
|
|
sx={{ justifyContent: 'flex-start' }}
|
|
>
|
|
{PROJECT_ICONS.find(icon => icon.value === projectIcon)?.name ||
|
|
'Select Icon'}
|
|
</Button>
|
|
</FormControl>
|
|
|
|
{/* Color Selection */}
|
|
<FormControl>
|
|
<FormLabel>Project Color</FormLabel>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
|
{PROJECT_COLORS.map(colorOption => (
|
|
<Box
|
|
key={colorOption.value}
|
|
title={colorOption.name}
|
|
onClick={() => setProjectColor(colorOption.value)}
|
|
sx={{
|
|
width: 26,
|
|
height: 26,
|
|
borderRadius: '50%',
|
|
background: colorOption.value,
|
|
cursor: 'pointer',
|
|
outline:
|
|
projectColor === colorOption.value
|
|
? '3px solid var(--joy-palette-primary-500)'
|
|
: '2px solid transparent',
|
|
outlineOffset: '2px',
|
|
transition: 'all 0.15s ease',
|
|
flexShrink: 0,
|
|
'&:hover': { transform: 'scale(1.2)' },
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</FormControl>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<Typography color='danger' level='body-sm'>
|
|
{error}
|
|
</Typography>
|
|
)}
|
|
</Stack>
|
|
</form>
|
|
<IconPickerModal
|
|
isOpen={isIconPickerOpen}
|
|
onClose={() => setIsIconPickerOpen(false)}
|
|
onSelect={handleIconSelect}
|
|
currentIcon={projectIcon}
|
|
projectColor={projectColor}
|
|
/>
|
|
</ResponsiveModal>
|
|
)
|
|
}
|
|
|
|
export default ProjectModal
|