Files
donetick/src/views/Projects/ProjectView.jsx
everysingletear 72aa57f018 i18n: extract things, history, projects, labels, filters, timer and points
Part of #145.

Fourteen files across seven feature areas that had no namespace yet: the
things create/edit modals and their history, the chore history detail and
edit modals, the activity feed, the points view and its redemption modal,
the project view with its selector and icon picker, the label view, the
advanced filter builder and the timer edit modal.

Seven new namespaces registered in `src/i18n/config.js` in one change
rather than one per PR, so the `ns:` array is touched once and my other
extraction PRs cannot conflict with this one. Namespaces stay
feature-scoped as described in #145; if you'd rather fold any of these
into `common` or `chores`, say which and I'll rework it.

Dictionaries: `history` 57 keys, `points` 50, `timer` 25, `projects` 16,
`things` 14, `filters` 13, `labels` 5.

English only — no translations, no behaviour change. Every t() value is
checked against this branch's base: the string must appear
character-for-character in the code it replaces (226 call sites).

Three values in `UserPoints` are matched loosely and worth naming. The
base builds the leaderboard heading and subtitle around a ternary —
`{mode === 'points' ? 'Points' : 'Tasks'} Leaderboard` and `Rankings based
on {…} during the selected time period` — so neither full sentence exists
contiguously in the source. Each key holds exactly what one branch
renders. The sentences are kept whole rather than split around the
ternary, since a sentence assembled from fragments cannot be reordered by
a translator.
2026-08-13 10:21:57 +08:00

520 lines
15 KiB
JavaScript

import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
Avatar,
Box,
Chip,
CircularProgress,
Container,
IconButton,
Stack,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ProjectModal from '../Modals/Inputs/ProjectModal'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import { Add, MoreVert, Task } from '@mui/icons-material'
import { useQueryClient } from '@tanstack/react-query'
import { useChores } from '../../queries/ChoreQueries'
import { useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
import { DeleteProject } from '../../utils/Fetcher'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useProjects } from './ProjectQueries'
const ProjectCardContent = ({
project,
currentUserId,
taskCounts = {},
onCardClick,
onToggleActions,
}) => {
const { t } = useTranslation('projects')
// Check if current user owns this project
const isOwnedByCurrentUser = project.created_by === currentUserId
const isDefaultProject = project.id === 'default'
const taskCount = taskCounts[project.id] || 0
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
width: '100%',
px: 2,
py: 1.5,
bgcolor: 'background.body',
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
}}
onClick={onCardClick}
>
{/* Project Avatar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
mr: 2,
flexShrink: 0,
}}
>
<Avatar
size='sm'
sx={{
width: 32,
height: 32,
bgcolor: project.color || 'primary.500',
border: '2px solid',
borderColor: isDefaultProject
? 'primary.300'
: isOwnedByCurrentUser
? 'background.surface'
: 'warning.300',
boxShadow: isDefaultProject
? '0 0 0 1px var(--joy-palette-primary-300)'
: isOwnedByCurrentUser
? 'sm'
: '0 0 0 1px var(--joy-palette-warning-300)',
}}
>
{project.icon ? (
(() => {
const IconComponent = getIconComponent(project.icon)
return (
<IconComponent
sx={{
fontSize: 16,
color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
}}
/>
)
})()
) : (
<></>
)}
</Avatar>
</Box>
{/* Content - Center */}
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}}
>
{/* Project Name */}
<Typography
level='title-sm'
sx={{
fontWeight: 600,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.25,
}}
>
{project.name}
{isDefaultProject && (
<Chip
size='sm'
variant='soft'
color='primary'
sx={{
fontSize: 9,
height: 16,
px: 0.5,
ml: 1,
fontWeight: 'md',
}}
>
{t('defaultChip')}
</Chip>
)}
</Typography>
{/* Project Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{project.description && (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '200px',
}}
>
{project.description}
</Typography>
)}
<Chip
size='sm'
variant='soft'
startDecorator={<Task />}
sx={{
fontSize: 10,
height: 18,
px: 0.75,
bgcolor: 'primary.softBg',
color: 'primary.500',
}}
>
{t('tasks', { count: taskCount })}
</Chip>
{!isOwnedByCurrentUser && !isDefaultProject && (
<Chip
size='sm'
variant='soft'
color='warning'
sx={{
fontSize: 9,
height: 16,
px: 0.5,
fontWeight: 'md',
}}
>
{t('shared')}
</Chip>
)}
</Box>
</Box>
<Box>
{onToggleActions && (
<IconButton
color='neutral'
variant='plain'
size='sm'
onClick={e => {
e.stopPropagation()
onToggleActions()
}}
>
<MoreVert sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
</Box>
)
}
const ProjectView = () => {
const { t } = useTranslation('projects')
const { data: projects, isProjectsLoading, isError } = useProjects()
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
const { setSelectedProjectWithCache } = useProjectFilter(
projectsData,
!projectsLoading,
)
const navigate = useNavigate()
const [userProjects, setUserProjects] = useState([])
const [modalOpen, setModalOpen] = useState(false)
const [currentProject, setCurrentProject] = useState(null)
const [taskCounts, setTaskCounts] = useState({})
const queryClient = useQueryClient()
const [confirmationModel, setConfirmationModel] = useState({})
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const handleAddProject = () => {
setCurrentProject(null)
setModalOpen(true)
}
const handleEditProject = project => {
setCurrentProject(project)
setModalOpen(true)
}
const handleDeleteClicked = id => {
const project = userProjects.find(p => p.id === id)
setConfirmationModel({
isOpen: true,
title: t('delete.title'),
message: t('delete.message', { name: project?.name }),
confirmText: t('common:delete'),
color: 'danger',
cancelText: t('common:cancel'),
onClose: confirmed => {
if (confirmed === true) {
handleDeleteProject(id)
}
setConfirmationModel({})
},
})
}
const handleDeleteProject = id => {
DeleteProject(id).then(() => {
const updatedProjects = userProjects.filter(project => project.id !== id)
setUserProjects(updatedProjects)
queryClient.invalidateQueries('projects')
// If the deleted project was the active project, clear it
const saved = localStorage.getItem('selectedProject')
if (saved) {
const savedProject = JSON.parse(saved)
if (savedProject && savedProject.id === id) {
setSelectedProjectWithCache(null)
}
}
})
}
const handleSaveProject = () => {
setModalOpen(false)
}
const handleCardClick = project => {
// Always navigate to MyChores with project filter when clicking on the card
// For default project, use 'default', for others use project ID
const projectIdentifier = project.id === 'default' ? 'default' : project.id
setSelectedProjectWithCache(project)
navigate(`/chores?project=${encodeURIComponent(projectIdentifier)}`)
}
useEffect(() => {
if (projects) {
setUserProjects(projects)
}
}, [projects])
// Calculate real task counts from chores data
useEffect(() => {
if (chores && chores.res) {
const choresList = chores.res
const realCounts = {}
// First, count tasks for the default project (tasks without a projectId)
const defaultProjectCount = choresList.filter(chore => {
const choreProjectId = chore.projectId || chore.project_id
return (
!choreProjectId ||
choreProjectId === '' ||
choreProjectId === 'default' ||
choreProjectId === null
)
}).length
realCounts['default'] = defaultProjectCount
// Then count tasks for each user project
userProjects.forEach(project => {
const choreCount = choresList.filter(chore => {
const choreProjectId = chore.projectId || chore.project_id
return choreProjectId === project.id
}).length
realCounts[project.id] = choreCount
})
setTaskCounts(realCounts)
}
}, [chores, userProjects])
if (isProjectsLoading) {
return (
<Box
display='flex'
justifyContent='center'
alignItems='center'
height='100vh'
>
<CircularProgress />
</Box>
)
}
if (isError) {
return (
<Typography color='danger' textAlign='center'>
{t('loadError')}
</Typography>
)
}
return (
<Container maxWidth='md' sx={{ px: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
{t('common:navigation.projects')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('blurb')}
</Typography>
</Stack>
</Box>
<Box
sx={{
overflow: 'hidden',
}}
>
{/* Default project - not swipeable */}
<ProjectCardContent
project={{
id: 'default',
name: t('chores:toolbar.defaultProject'),
description: t('defaultDescription'),
icon: 'FolderOpen',
color: '#1976d2',
created_by: userProfile?.id,
}}
currentUserId={userProfile?.id}
taskCounts={{ default: taskCounts.default || 0 }}
onCardClick={() =>
handleCardClick({
id: 'default',
name: t('chores:toolbar.defaultProject'),
icon: 'FolderOpen',
color: '#1976d2',
})
}
/>
{/* User projects - swipeable */}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{userProjects.map(project => (
<SwipeableListItem
onClick={() => handleCardClick(project)}
key={project.id}
swipeActionOpen={
showMoreInfoId === project.id ? 'trailing' : null
}
trailingActions={
<TrailingActions>
<Box
sx={{
display: 'flex',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<SwipeAction onClick={() => handleEditProject(project)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'neutral.softBg',
color: 'neutral.700',
px: 3,
height: '100%',
}}
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
{t('common:edit')}
</Typography>
</Box>
</SwipeAction>
<SwipeAction
onClick={() => handleDeleteClicked(project.id)}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.700',
px: 3,
height: '100%',
}}
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
{t('common:delete')}
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
<ProjectCardContent
project={project}
currentUserId={userProfile?.id}
taskCounts={taskCounts}
onToggleActions={() => {
if (showMoreInfoId === project.id) {
setShowMoreInfoId(null)
} else {
setShowMoreInfoId(project.id)
}
}}
/>
</SwipeableListItem>
))}
</SwipeableList>
</Box>
{modalOpen && (
<ProjectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveProject}
project={currentProject}
/>
)}
<Box
sx={{
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
left: 10,
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 1000,
}}
>
<IconButton
data-testid='open-add-project-modal'
color='primary'
variant='solid'
sx={{
borderRadius: '50%',
width: 50,
height: 50,
}}
onClick={handleAddProject}
>
<Add />
</IconButton>
</Box>
<ConfirmationModal config={confirmationModel} />
</Container>
)
}
export default ProjectView