Merge pull request #210 from everysingletear/i18n/chores

i18n: extract the chore list and its modals (Part of #145)
This commit is contained in:
Mohamad Tarbin
2026-08-15 00:03:09 -04:00
committed by GitHub
25 changed files with 542 additions and 273 deletions

View File

@@ -100,11 +100,24 @@
"saveFilter": "Save Filter", "saveFilter": "Save Filter",
"saveAsNew": "Save as New Filter", "saveAsNew": "Save as New Filter",
"groupBy": "Group by", "groupBy": "Group by",
"showTasksFor": "Show tasks for" "showTasksFor": "Show tasks for",
"type": {
"assignee": "Assignee"
}
}, },
"sort": { "sort": {
"assignedToMe": "Assigned to me", "assignedToMe": "Assigned to me",
"smart": "Smart" "smart": "Smart",
"createdDate": "Created Date",
"updatedDate": "Updated Date",
"sortAndGroup": "Sort and Group (Ctrl+G)",
"groupBy": "Group By",
"quickFilters": "Quick Filters",
"assignedTo": "Assigned to:",
"availableForMe": "Available for me",
"assignedToOthers": "Assigned to others",
"createFilter": "Create Filter",
"createFilterHint": "Build advanced filter rules"
}, },
"thing": { "thing": {
"triggerHint": "Trigger a task when a thing state changes to a desired state", "triggerHint": "Trigger a task when a thing state changes to a desired state",
@@ -124,7 +137,9 @@
"dueToday": "Due Today", "dueToday": "Due Today",
"pendingApproval": "Pending Approval", "pendingApproval": "Pending Approval",
"dueThisWeek": "Due This Week", "dueThisWeek": "Due This Week",
"noDueDate": "No Due Date" "noDueDate": "No Due Date",
"dueDate": "Due Date",
"dueLater": "Due Later"
}, },
"insights": { "insights": {
"highPriority": "High Priority" "highPriority": "High Priority"
@@ -134,7 +149,16 @@
}, },
"actionMenu": { "actionMenu": {
"view": "View", "view": "View",
"archive": "Archive" "archive": "Archive",
"writeNFC": "Write to NFC",
"completeWithNote": "Complete with note",
"completeInPast": "Complete in past",
"skipToNext": "Skip to next due date",
"sendNudge": "Send nudge",
"history": "History",
"clone": "Clone",
"moveToProject": "Move to project",
"removeDueDate": "Remove due date"
}, },
"actions": { "actions": {
"rescheduledTitle": "Task Rescheduled", "rescheduledTitle": "Task Rescheduled",
@@ -187,18 +211,195 @@
"someFailedTitle": "Some Tasks Failed", "someFailedTitle": "Some Tasks Failed",
"unexpectedError": "An unexpected error occurred. Please try again.", "unexpectedError": "An unexpected error occurred. Please try again.",
"deletedBulkTitle": "🗑️ Tasks Deleted", "deletedBulkTitle": "🗑️ Tasks Deleted",
"bulkDeleteFailTitle": "Bulk Delete Failed" "bulkDeleteFailTitle": "Bulk Delete Failed",
"archivedDate": "Archived Date",
"loadFailTitle": "Failed to load archived tasks",
"loadFailMsg": "Please try again later.",
"deletedMsg": "The archived task has been permanently deleted.",
"restoreTasksTitle": "Restore Tasks",
"restore": "Restore",
"restoreConfirm_one": "Restore {{count}} task to active list?",
"restoreConfirm_other": "Restore {{count}} tasks to active list?",
"restoredBulkTitle": "📤 Tasks Restored",
"restoredCount_one": "Restored {{count}} task{{offline}}.",
"restoredCount_other": "Restored {{count}} tasks{{offline}}.",
"restoreFailCount_one": "{{count}} task could not be restored.",
"restoreFailCount_other": "{{count}} tasks could not be restored.",
"bulkRestoreFailTitle": "Bulk Restore Failed",
"deleteTasksTitle": "Delete Archived Tasks",
"deleteConfirm_one": "Permanently delete {{count}} archived task?\n\nThis action cannot be undone.",
"deleteConfirm_other": "Permanently delete {{count}} archived tasks?\n\nThis action cannot be undone.",
"deletedCount_one": "Successfully deleted {{count}} task.",
"deletedCount_other": "Successfully deleted {{count}} tasks.",
"deleteFailCount_one": "{{count}} task could not be deleted.",
"deleteFailCount_other": "{{count}} tasks could not be deleted.",
"title": "Archived Tasks",
"subtitle": "View and manage tasks that have been archived or completed.",
"heading": "Archived Tasks",
"searchPlaceholder": "Search archived tasks",
"switchToCompact": "Switch to Compact View",
"switchToCard": "Switch to Card View",
"exitMultiSelect": "Exit Multi-select Mode (Ctrl+S)",
"enableMultiSelect": "Enable Multi-select Mode (Ctrl+S)",
"selected_one": "{{count}} task selected",
"selected_other": "{{count}} tasks selected",
"selectAllTitle": "Select all visible tasks (Ctrl+A)",
"all": "All",
"closeMultiSelect": "Close multi-select (Esc)",
"clearMultiSelect": "Clear multi-select (Esc)",
"close": "Close",
"clear": "Clear",
"restoreSelectedTitle": "Restore selected tasks (R)",
"deleteSelectedTitle": "Delete selected tasks (E)",
"clearSearch": "Clear search",
"clearFilters": "Clear filters",
"count_one": "{{count}} archived task",
"count_other": "{{count}} archived tasks",
"matching": " matching \"{{term}}\""
}, },
"edit": { "edit": {
"deleteConfirm": "Are you sure you want to delete this chore?" "deleteConfirm": "Are you sure you want to delete this chore?"
}, },
"list": { "list": {
"complete": "Complete" "complete": "Complete",
"filterSaved": "Filter Saved",
"filterSavedMsg": "\"{{name}}\" has been saved",
"nothingScheduled": "Nothing scheduled",
"createChore": "Create new chore (Cmd+C)",
"filterUpdated": "Filter Updated",
"filterUpdatedMsg": "\"{{name}}\" has been updated successfully",
"advancedFilterCreated": "Advanced Filter Created",
"advancedFilterCreatedMsg": "\"{{name}}\" has been created successfully"
}, },
"multiToolbar": { "multiToolbar": {
"skip": "Skip" "skip": "Skip",
"completeTitle": "Complete selected tasks (Enter)",
"complete": "Complete",
"skipTitle": "Skip selected tasks (/)",
"archiveTitle": "Archive selected tasks (X)",
"archive": "Archive",
"deleteTitle": "Delete selected tasks (Shift+X)"
}, },
"remind": { "remind": {
"title": "Reminders" "title": "Reminders"
},
"assignee": {
"anyone": "Anyone"
},
"filterChip": {
"cancelAll": "Cancel All Filters",
"unpin": "Unpin filter",
"pin": "Pin filter",
"edit": "Edit filter",
"delete": "Delete filter"
},
"multiSelect": {
"showShortcuts": "Show keyboard shortcuts",
"title": "Multi-select Mode",
"selection": "Selection",
"selectAll": "Select all visible tasks",
"clearOrExit": "Clear selection or exit multi-select mode",
"actions": "Actions",
"markCompleted": "Mark selected tasks as completed",
"deleteSelected": "Delete selected tasks",
"interface": "Interface",
"quickAdd": "Quick add new task"
},
"notifications": {
"needTitle": "Need Notification?",
"needBody": "You need to enable permission to receive notifications, do you want to enable it?",
"keepDisabled": "No, Keep it Disabled"
},
"assigneeCard": {
"loading": "Loading tasks by assignee...",
"title": "Tasks by Assignee",
"scheduled": "Scheduled",
"pendingReview": "Pending Review"
},
"filter": {
"status": {
"inProgress": "In Progress"
}
},
"impersonate": {
"viewAs": "View tasks as",
"switchTitle": "Switch to user view",
"switchHint": "Tasks will be filtered to show only assignments for selected user",
"chooseUser": "Choose User",
"changeUser": "Change User"
},
"modals": {
"changeDueDate": "Change due date",
"completePast": "Save Chore that you completed in the past",
"delegate": "Delegate to someone else",
"selectPerformer": "Select a performer",
"addNote": "Add note to attach to this completion:",
"complete": "Complete"
},
"shortcuts": {
"allSelectedTitle": "✅ All Tasks Selected",
"someSelectedTitle": "🎯 Tasks Selected",
"allSelectedAltTitle": "🎯 All Tasks Selected"
},
"overview": {
"title": "Chores Overviews",
"search": "Search",
"newChore": "New Chore",
"unassigned": "Unassigned",
"changeDueDate": "Change due date"
},
"dataError": {
"attachmentsFailed": "Failed to fetch attachments"
},
"nudge": {
"title": "Send Nudge",
"customMessage": "Custom Message (optional)",
"messagePlaceholder": "Add a personal message with your nudge...",
"notifyAll": "Notify All Assignees",
"notifyAllHint": "If enabled, all members who can see this task will be notified. Otherwise, only the assigned person will receive the nudge."
},
"nfc": {
"errWrite": "Error writing to NFC tag. Please try again.",
"errUnsupported": "NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.",
"titleSuccess": "Tag written!",
"titleError": "Something went wrong",
"titleWaiting": "Hold near NFC tag",
"subSuccess": "Your NFC tag is ready to use.",
"subWaiting": "Keep your device near the tag until complete.",
"subIdle": "Encode this task link onto any NFC tag.",
"tagUrl": "Tag URL",
"copyUrl": "Copy URL",
"autoComplete": "Auto-complete on scan",
"autoCompleteHint": "Mark task done when tag is tapped"
},
"duePicker": {
"today": "Today",
"tomorrow": "Tomorrow",
"weekend": "Weekend",
"nextWeek": "Next week"
},
"photoTask": {
"title": "Scan photo to create task",
"cameraUnavailable": "Camera not available",
"uploadPhoto": "Upload Photo",
"scanDocument": "Scan Document",
"capture": "Capture",
"processNatively": "Process Natively",
"processImage": "Process Image",
"createTask": "Create Task",
"failedScan": "Failed scan"
},
"descriptionPlaceholder": "Enter description...",
"notifTemplate": {
"onDueDate": "On due date",
"beforeDue": "{{count}} {{unit}} before due",
"afterDue": "{{count}} {{unit}} after due",
"errDuplicate": "This notification setting already exists. Please use a different timing.",
"errOneDue": "Only one \"Due Alert\" notification is allowed.",
"errAllConfigured": "All common {{type}} times are already configured.",
"reminder": "Reminder",
"dueAlert": "Due Alert",
"followUp": "Follow-up",
"rememberFuture": "Remember for Future Tasks"
} }
} }

View File

@@ -131,7 +131,14 @@
"tooLargeTitle": "File Too Large", "tooLargeTitle": "File Too Large",
"tooLargeMessage": "The file you are trying to upload is too large.", "tooLargeMessage": "The file you are trying to upload is too large.",
"deniedTitle": "Permission Denied", "deniedTitle": "Permission Denied",
"deniedMessage": "You do not have permission to upload files." "deniedMessage": "You do not have permission to upload files.",
"plusFeatureTitle": "Plus Feature",
"plusFeatureMessage": "Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.",
"upgradeTitle": "Upgrade Required",
"upgradeMessage": "Image uploads are only available for Plus accounts.",
"failedTitle": "Upload Failed",
"failedMessage": "Failed to upload image.",
"processingMessage": "An error occurred while processing the image."
}, },
"getStarted": "Get Started!", "getStarted": "Get Started!",
"imageLoadFailed": "Failed to load image.", "imageLoadFailed": "Failed to load image.",
@@ -191,5 +198,7 @@
"allSynced": "All changes synced", "allSynced": "All changes synced",
"willSync": "Will sync when back online", "willSync": "Will sync when back online",
"cancelAll": "Cancel All" "cancelAll": "Cancel All"
} },
"attachments": "Attachments",
"fileNumbered": "File {{index}}"
} }

View File

@@ -13,26 +13,29 @@ import Option from '@mui/joy/Option'
import Select from '@mui/joy/Select' import Select from '@mui/joy/Select'
import Typography from '@mui/joy/Typography' import Typography from '@mui/joy/Typography'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
import { TIME_UNITS } from '../utils/DurationUtils' import { TIME_UNITS } from '../utils/DurationUtils'
const timeUnits = TIME_UNITS const timeUnits = TIME_UNITS
const timingOptions = [ const timingOptions = [
{ label: 'Before', value: 'before' }, { value: 'before' },
{ label: 'Due', value: 'ondue' }, { value: 'ondue' },
{ label: 'After', value: 'after' }, { value: 'after' },
] ]
function getRelativeLabel(notification) { function getRelativeLabel(notification, t) {
const { value, unit } = notification const { value, unit } = notification
const numericValue = Number(value) const numericValue = Number(value)
if (numericValue === 0) { if (numericValue === 0) {
return 'On due date' return t('notifTemplate.onDueDate')
} }
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days' const unitName = t(`notifTemplate.unitName.${unit}`)
const absValue = Math.abs(numericValue) const absValue = Math.abs(numericValue)
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due` return numericValue < 0
? t('notifTemplate.beforeDue', { count: absValue, unit: unitName })
: t('notifTemplate.afterDue', { count: absValue, unit: unitName })
} }
// Helper functions to convert between internal value and UI representation // Helper functions to convert between internal value and UI representation
@@ -71,6 +74,7 @@ const NotificationTemplate = ({
value, value,
showTimeline = true, showTimeline = true,
}) => { }) => {
const { t } = useTranslation('chores')
const [notifications, setNotifications] = useState( const [notifications, setNotifications] = useState(
value?.templates || value?.templates ||
JSON.parse(localStorage.getItem('defaultNotificationTemplate')) || JSON.parse(localStorage.getItem('defaultNotificationTemplate')) ||
@@ -218,9 +222,7 @@ const NotificationTemplate = ({
if (!currentNotification) return if (!currentNotification) return
if (isDuplicate(currentNotification, idx, currentList)) { if (isDuplicate(currentNotification, idx, currentList)) {
setError( setError(t('notifTemplate.errDuplicate'))
'This notification setting already exists. Please use a different timing.',
)
return return
} }
} }
@@ -232,14 +234,14 @@ const NotificationTemplate = ({
if (type === 'due') { if (type === 'due') {
if (notificationsRef.current.some(n => Number(n.value) === 0)) { if (notificationsRef.current.some(n => Number(n.value) === 0)) {
setError('Only one "Due Alert" notification is allowed.') setError(t('notifTemplate.errOneDue'))
return return
} }
newNotification = { value: 0, unit: 'm' } newNotification = { value: 0, unit: 'm' }
} else { } else {
newNotification = getSmartSuggestion(type) newNotification = getSmartSuggestion(type)
if (!newNotification) { if (!newNotification) {
setError(`All common ${type} times are already configured.`) setError(t('notifTemplate.errAllConfigured', { type }))
return return
} }
} }
@@ -364,7 +366,7 @@ const NotificationTemplate = ({
fontSize: '0.6rem', fontSize: '0.6rem',
}} }}
> >
Due Date {t('group.dueDate')}
</Typography> </Typography>
</Box> </Box>
@@ -398,7 +400,7 @@ const NotificationTemplate = ({
zIndex: 10, zIndex: 10,
}, },
}} }}
title={getRelativeLabel(n)} title={getRelativeLabel(n, t)}
> >
<Badge <Badge
badgeContent={ badgeContent={
@@ -550,7 +552,7 @@ const NotificationTemplate = ({
fontSize: 14, fontSize: 14,
}} }}
> >
{getRelativeLabel(n)} {getRelativeLabel(n, t)}
</Typography> </Typography>
</Box> </Box>
@@ -575,7 +577,7 @@ const NotificationTemplate = ({
value={opt.value} value={opt.value}
disabled={opt.value === 'ondue' && hasOnDueElsewhere} disabled={opt.value === 'ondue' && hasOnDueElsewhere}
> >
{opt.label} {t(`notifTemplate.timing.${opt.value}`)}
</Option> </Option>
))} ))}
</Select> </Select>
@@ -638,7 +640,7 @@ const NotificationTemplate = ({
> >
{timeUnits.map(opt => ( {timeUnits.map(opt => (
<Option key={opt.value} value={opt.value}> <Option key={opt.value} value={opt.value}>
{opt.label} {t(`notifTemplate.unitShort.${opt.value}`)}
</Option> </Option>
))} ))}
</Select> </Select>
@@ -688,7 +690,7 @@ const NotificationTemplate = ({
}, },
}} }}
> >
Reminder {t('notifTemplate.reminder')}
</Button> </Button>
<Button <Button
onClick={() => addSmartNotification('due')} onClick={() => addSmartNotification('due')}
@@ -710,7 +712,7 @@ const NotificationTemplate = ({
}, },
}} }}
> >
Due Alert {t('notifTemplate.dueAlert')}
</Button> </Button>
<Button <Button
onClick={() => addSmartNotification('followup')} onClick={() => addSmartNotification('followup')}
@@ -729,7 +731,7 @@ const NotificationTemplate = ({
}, },
}} }}
> >
Follow-up {t('notifTemplate.followUp')}
</Button> </Button>
</Box> </Box>
{showSaveDefault && ( {showSaveDefault && (
@@ -762,7 +764,7 @@ const NotificationTemplate = ({
setShowSaveDefault(false) setShowSaveDefault(false)
}} }}
> >
Remember for Future Tasks {t('notifTemplate.rememberFuture')}
</Button> </Button>
</Box> </Box>
)} )}

View File

@@ -27,6 +27,7 @@ import {
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState' import EmptyState from '../../components/common/EmptyState'
@@ -92,6 +93,7 @@ const applyPendingArchivedState = async chores => {
} }
const ArchivedTasks = () => { const ArchivedTasks = () => {
const { t } = useTranslation('chores')
const { data: userProfile, isLoading: isUserProfileLoading } = const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile() useUserProfile()
const { showError, showSuccess } = useNotification() const { showError, showSuccess } = useNotification()
@@ -132,7 +134,7 @@ const ArchivedTasks = () => {
() => [ () => [
{ {
id: 'assignee', id: 'assignee',
label: 'Assignee', label: t('toolbar.type.assignee'),
type: 'multi-select', type: 'multi-select',
icon: <Person />, icon: <Person />,
options: performers.map(p => ({ options: performers.map(p => ({
@@ -144,7 +146,7 @@ const ArchivedTasks = () => {
}, },
{ {
id: 'priority', id: 'priority',
label: 'Priority', label: t('priority'),
type: 'multi-select', type: 'multi-select',
icon: <PriorityHigh />, icon: <PriorityHigh />,
options: Priorities.map(p => ({ options: Priorities.map(p => ({
@@ -159,7 +161,7 @@ const ArchivedTasks = () => {
? [ ? [
{ {
id: 'label', id: 'label',
label: 'Labels', label: t('labels.label'),
type: 'multi-select', type: 'multi-select',
icon: <Label />, icon: <Label />,
options: availableLabels.map(l => ({ options: availableLabels.map(l => ({
@@ -186,7 +188,7 @@ const ArchivedTasks = () => {
: []), : []),
{ {
id: 'archivedAt', id: 'archivedAt',
label: 'Archived Date', label: t('archived.archivedDate'),
type: 'date-range', type: 'date-range',
icon: <Archive />, icon: <Archive />,
filterFn: (item, value) => { filterFn: (item, value) => {
@@ -197,7 +199,7 @@ const ArchivedTasks = () => {
}, },
}, },
], ],
[performers, availableLabels], [performers, availableLabels, t],
) )
const { const {
@@ -233,8 +235,8 @@ const ArchivedTasks = () => {
setFilteredChores(sortedChores) setFilteredChores(sortedChores)
} catch { } catch {
showError({ showError({
title: 'Failed to load archived tasks', title: t('archived.loadFailTitle'),
message: 'Please try again later.', message: t('archived.loadFailMsg'),
}) })
} }
} finally { } finally {
@@ -386,8 +388,8 @@ const ArchivedTasks = () => {
setFilteredChores(newFilteredChores) setFilteredChores(newFilteredChores)
showSuccess({ showSuccess({
title: 'Task Restored', title: t('archived.restoredTitle'),
message: 'The task has been restored and is now active.', message: t('archived.restoredMsg'),
}) })
} }
} }
@@ -403,8 +405,8 @@ const ArchivedTasks = () => {
setFilteredChores(newFilteredChores) setFilteredChores(newFilteredChores)
showSuccess({ showSuccess({
title: 'Task Deleted', title: t('archived.deletedTitle'),
message: 'The archived task has been permanently deleted.', message: t('archived.deletedMsg'),
}) })
} }
@@ -465,10 +467,10 @@ const ArchivedTasks = () => {
setConfirmModelConfig({ setConfirmModelConfig({
isOpen: true, isOpen: true,
title: 'Restore Tasks', title: t('archived.restoreTasksTitle'),
confirmText: 'Restore', confirmText: t('archived.restore'),
cancelText: 'Cancel', cancelText: t('common:cancel'),
message: `Restore ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to active list?`, message: t('archived.restoreConfirm', { count: selectedData.length }),
onClose: async isConfirmed => { onClose: async isConfirmed => {
if (isConfirmed === true) { if (isConfirmed === true) {
try { try {
@@ -533,23 +535,28 @@ const ArchivedTasks = () => {
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] }) queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
} }
showSuccess({ showSuccess({
title: '📤 Tasks Restored', title: t('archived.restoredBulkTitle'),
message: `Restored ${allRestored.length} task${allRestored.length > 1 ? 's' : ''}${offlineNote}.`, message: t('archived.restoredCount', {
count: allRestored.length,
offline: offlineNote,
}),
}) })
} }
if (failedTasks.length > 0) { if (failedTasks.length > 0) {
showError({ showError({
title: 'Some Tasks Failed', title: t('archived.someFailedTitle'),
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be restored.`, message: t('archived.restoreFailCount', {
count: failedTasks.length,
}),
}) })
} }
clearSelection() clearSelection()
} catch (error) { } catch (error) {
showError({ showError({
title: 'Bulk Restore Failed', title: t('archived.bulkRestoreFailTitle'),
message: 'An unexpected error occurred. Please try again.', message: t('archived.unexpectedError'),
}) })
} }
} }
@@ -564,10 +571,10 @@ const ArchivedTasks = () => {
setConfirmModelConfig({ setConfirmModelConfig({
isOpen: true, isOpen: true,
title: 'Delete Archived Tasks', title: t('archived.deleteTasksTitle'),
confirmText: 'Delete', confirmText: t('archived.delete'),
cancelText: 'Cancel', cancelText: t('common:cancel'),
message: `Permanently delete ${selectedData.length} archived task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`, message: t('archived.deleteConfirm', { count: selectedData.length }),
onClose: async isConfirmed => { onClose: async isConfirmed => {
if (isConfirmed === true) { if (isConfirmed === true) {
try { try {
@@ -585,8 +592,10 @@ const ArchivedTasks = () => {
if (deletedTasks.length > 0) { if (deletedTasks.length > 0) {
showSuccess({ showSuccess({
title: '🗑️ Tasks Deleted', title: t('archived.deletedBulkTitle'),
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`, message: t('archived.deletedCount', {
count: deletedTasks.length,
}),
}) })
const deletedIds = new Set(deletedTasks.map(c => c.id)) const deletedIds = new Set(deletedTasks.map(c => c.id))
@@ -602,16 +611,18 @@ const ArchivedTasks = () => {
if (failedTasks.length > 0) { if (failedTasks.length > 0) {
showError({ showError({
title: 'Some Tasks Failed', title: t('archived.someFailedTitle'),
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`, message: t('archived.deleteFailCount', {
count: failedTasks.length,
}),
}) })
} }
clearSelection() clearSelection()
} catch (error) { } catch (error) {
showError({ showError({
title: 'Bulk Delete Failed', title: t('archived.bulkDeleteFailTitle'),
message: 'An unexpected error occurred. Please try again.', message: t('archived.unexpectedError'),
}) })
} }
} }
@@ -653,10 +664,10 @@ const ArchivedTasks = () => {
level='h3' level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }} sx={{ fontWeight: 'lg', color: 'text.primary' }}
> >
Archived Tasks {t('archived.title')}
</Typography> </Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}> <Typography level='body-sm' sx={{ color: 'text.secondary' }}>
View and manage tasks that have been archived or completed. {t('archived.subtitle')}
</Typography> </Typography>
</Stack> </Stack>
</Box> </Box>
@@ -675,7 +686,7 @@ const ArchivedTasks = () => {
sx={{ display: 'flex', alignItems: 'center', gap: 1 }} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
> >
<Archive /> <Archive />
Archived Tasks {t('archived.heading')}
</Typography> </Typography>
<Button <Button
variant='outlined' variant='outlined'
@@ -701,7 +712,7 @@ const ArchivedTasks = () => {
> >
<Input <Input
slotProps={{ input: { ref: searchInputRef } }} slotProps={{ input: { ref: searchInputRef } }}
placeholder='Search archived tasks' placeholder={t('archived.searchPlaceholder')}
value={searchTerm} value={searchTerm}
fullWidth fullWidth
sx={{ sx={{
@@ -747,8 +758,8 @@ const ArchivedTasks = () => {
onClick={toggleViewMode} onClick={toggleViewMode}
title={ title={
viewMode === 'default' viewMode === 'default'
? 'Switch to Compact View' ? t('archived.switchToCompact')
: 'Switch to Card View' : t('archived.switchToCard')
} }
> >
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />} {viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
@@ -768,8 +779,8 @@ const ArchivedTasks = () => {
onClick={toggleMultiSelectMode} onClick={toggleMultiSelectMode}
title={ title={
isMultiSelectMode isMultiSelectMode
? 'Exit Multi-select Mode (Ctrl+S)' ? t('archived.exitMultiSelect')
: 'Enable Multi-select Mode (Ctrl+S)' : t('archived.enableMultiSelect')
} }
> >
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />} {isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
@@ -850,8 +861,7 @@ const ArchivedTasks = () => {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckBox sx={{ color: 'primary.500' }} /> <CheckBox sx={{ color: 'primary.500' }} />
<Typography level='body-sm' fontWeight='md'> <Typography level='body-sm' fontWeight='md'>
{selectedChores.size} task {t('archived.selected', { count: selectedChores.size })}
{selectedChores.size !== 1 ? 's' : ''} selected
</Typography> </Typography>
</Box> </Box>
@@ -874,9 +884,9 @@ const ArchivedTasks = () => {
'--Button-paddingInline': '0.75rem', '--Button-paddingInline': '0.75rem',
position: 'relative', position: 'relative',
}} }}
title='Select all visible tasks (Ctrl+A)' title={t('archived.selectAllTitle')}
> >
All {t('archived.all')}
{showKeyboardShortcuts && ( {showKeyboardShortcuts && (
<KeyboardShortcutHint <KeyboardShortcutHint
shortcut='A' shortcut='A'
@@ -905,9 +915,15 @@ const ArchivedTasks = () => {
'--Button-paddingInline': '0.75rem', '--Button-paddingInline': '0.75rem',
position: 'relative', position: 'relative',
}} }}
title={`${selectedChores.size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`} title={
selectedChores.size === 0
? t('archived.closeMultiSelect')
: t('archived.clearMultiSelect')
}
> >
{selectedChores.size === 0 ? 'Close' : 'Clear'} {selectedChores.size === 0
? t('archived.close')
: t('archived.clear')}
{showKeyboardShortcuts && ( {showKeyboardShortcuts && (
<KeyboardShortcutHint <KeyboardShortcutHint
withCtrl={false} withCtrl={false}
@@ -951,9 +967,9 @@ const ArchivedTasks = () => {
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative', position: 'relative',
}} }}
title='Restore selected tasks (R)' title={t('archived.restoreSelectedTitle')}
> >
Restore {t('archived.restore')}
{showKeyboardShortcuts && selectedChores.size > 0 && ( {showKeyboardShortcuts && selectedChores.size > 0 && (
<KeyboardShortcutHint <KeyboardShortcutHint
shortcut='R' shortcut='R'
@@ -978,9 +994,9 @@ const ArchivedTasks = () => {
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative', position: 'relative',
}} }}
title='Delete selected tasks (E)' title={t('archived.deleteSelectedTitle')}
> >
Delete {t('archived.delete')}
{showKeyboardShortcuts && selectedChores.size > 0 && ( {showKeyboardShortcuts && selectedChores.size > 0 && (
<KeyboardShortcutHint <KeyboardShortcutHint
shortcut='E' shortcut='E'
@@ -1013,12 +1029,12 @@ const ArchivedTasks = () => {
} }
primaryAction={ primaryAction={
searchTerm searchTerm
? { label: 'Clear search', onClick: handleSearchClose } ? { label: t('archived.clearSearch'), onClick: handleSearchClose }
: { label: 'Clear filters', onClick: clearAll } : { label: t('archived.clearFilters'), onClick: clearAll }
} }
secondaryAction={ secondaryAction={
searchTerm && hasActiveFilters searchTerm && hasActiveFilters
? { label: 'Clear filters', onClick: clearAll } ? { label: t('archived.clearFilters'), onClick: clearAll }
: undefined : undefined
} }
/> />
@@ -1034,9 +1050,8 @@ const ArchivedTasks = () => {
) : ( ) : (
<Box> <Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}> <Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{finalChores.length} archived task {t('archived.count', { count: finalChores.length })}
{finalChores.length !== 1 ? 's' : ''} {searchTerm && t('archived.matching', { term: searchTerm })}
{searchTerm && ` matching "${searchTerm}"`}
</Typography> </Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}> <List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>

View File

@@ -17,6 +17,7 @@ import {
ThumbDown, ThumbDown,
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Typography } from '@mui/joy' import { Box, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useLongPress } from '../../hooks/useLongPress' import { useLongPress } from '../../hooks/useLongPress'
import ChoreCard from './ChoreCard' import ChoreCard from './ChoreCard'
@@ -89,6 +90,7 @@ const ChoreListView = ({
showActions = true, showActions = true,
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation('chores')
const renderChoreCard = (chore, key) => { const renderChoreCard = (chore, key) => {
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
return ( return (
@@ -202,7 +204,7 @@ const ChoreListView = ({
<Check sx={{ fontSize: 20 }} /> <Check sx={{ fontSize: 20 }} />
)} )}
<Typography level='body-xs' sx={{ mt: 0.5 }}> <Typography level='body-xs' sx={{ mt: 0.5 }}>
{chore.status !== 1 ? 'Start' : 'Complete'} {chore.status !== 1 ? t('choreView.start') : t('list.complete')}
</Typography> </Typography>
</Box> </Box>
</SwipeAction> </SwipeAction>

View File

@@ -9,6 +9,7 @@ import {
Webhook, Webhook,
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy' import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
@@ -42,6 +43,7 @@ const CompactChoreCard = ({
onlyClickable = false, onlyClickable = false,
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation('chores')
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { timeFormat } = useLocalization() const { timeFormat } = useLocalization()
@@ -96,7 +98,7 @@ const CompactChoreCard = ({
if (assignee) parts.push(assignee) if (assignee) parts.push(assignee)
} }
if (chore.assignedTo === null) { if (chore.assignedTo === null) {
parts.push('Anyone') parts.push(t('assignee.anyone'))
} }
// Points // Points
@@ -404,7 +406,7 @@ const CompactChoreCard = ({
ml: 1, ml: 1,
}} }}
> >
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)} {getDueDateChipText(chore.nextDueDate, chore, timeFormat, t)}
</Chip> </Chip>
</Box> </Box>

View File

@@ -1,4 +1,5 @@
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy' import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import IconButton from '@mui/joy/IconButton' import IconButton from '@mui/joy/IconButton'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
@@ -15,6 +16,7 @@ const IconButtonWithMenu = ({
useChips, useChips,
title, title,
}) => { }) => {
const { t } = useTranslation('chores')
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null) const menuRef = useRef(null)
const menuOptions = Array.isArray(options) ? options : [] const menuOptions = Array.isArray(options) ? options : []
@@ -120,7 +122,7 @@ const IconButtonWithMenu = ({
setSelectedItem?.setSelectedItem('All') setSelectedItem?.setSelectedItem('All')
}} }}
> >
Cancel All Filters {t('filterChip.cancelAll')}
</MenuItem> </MenuItem>
)} */} )} */}
</Menu> </Menu>

View File

@@ -3,8 +3,10 @@ import { Box, Card, IconButton, Typography } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import ModalActions from '../../components/common/ModalActions' import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
const MultiSelectHelp = ({ isVisible = true }) => { const MultiSelectHelp = ({ isVisible = true }) => {
const { t } = useTranslation('chores')
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
const [isHelpOpen, setIsHelpOpen] = useState(false) const [isHelpOpen, setIsHelpOpen] = useState(false)
@@ -29,8 +31,8 @@ const MultiSelectHelp = ({ isVisible = true }) => {
borderRadius: '50%', borderRadius: '50%',
boxShadow: 'lg', boxShadow: 'lg',
}} }}
aria-label='Show keyboard shortcuts' aria-label={t('multiSelect.showShortcuts')}
title='Show keyboard shortcuts' title={t('multiSelect.showShortcuts')}
> >
<HelpOutline /> <HelpOutline />
</IconButton> </IconButton>
@@ -39,7 +41,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
<ResponsiveModal <ResponsiveModal
open={isHelpOpen} open={isHelpOpen}
onClose={() => setIsHelpOpen(false)} onClose={() => setIsHelpOpen(false)}
title='Multi-select Mode' title={t('multiSelect.title')}
description='Use these keyboard shortcuts to work more efficiently.' description='Use these keyboard shortcuts to work more efficiently.'
footer={ footer={
<ModalActions <ModalActions
@@ -51,16 +53,16 @@ const MultiSelectHelp = ({ isVisible = true }) => {
{/* Selection shortcuts */} {/* Selection shortcuts */}
<Card variant='soft' sx={{ p: 2 }}> <Card variant='soft' sx={{ p: 2 }}>
<Typography level='title-sm' sx={{ mb: 1.5, color: 'primary.600' }}> <Typography level='title-sm' sx={{ mb: 1.5, color: 'primary.600' }}>
Selection {t('multiSelect.selection')}
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem <ShortcutItem
keys={['Ctrl', 'A']} keys={['Ctrl', 'A']}
description='Select all visible tasks' description={t('multiSelect.selectAll')}
/> />
<ShortcutItem <ShortcutItem
keys={['Esc']} keys={['Esc']}
description='Clear selection or exit multi-select mode' description={t('multiSelect.clearOrExit')}
/> />
</Box> </Box>
</Card> </Card>
@@ -68,16 +70,16 @@ const MultiSelectHelp = ({ isVisible = true }) => {
{/* Action shortcuts */} {/* Action shortcuts */}
<Card variant='soft' sx={{ p: 2 }}> <Card variant='soft' sx={{ p: 2 }}>
<Typography level='title-sm' sx={{ mb: 1.5, color: 'success.600' }}> <Typography level='title-sm' sx={{ mb: 1.5, color: 'success.600' }}>
Actions {t('multiSelect.actions')}
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem <ShortcutItem
keys={['Enter']} keys={['Enter']}
description='Mark selected tasks as completed' description={t('multiSelect.markCompleted')}
/> />
<ShortcutItem <ShortcutItem
keys={['Del', '⌫']} keys={['Del', '⌫']}
description='Delete selected tasks' description={t('multiSelect.deleteSelected')}
/> />
</Box> </Box>
</Card> </Card>
@@ -85,12 +87,12 @@ const MultiSelectHelp = ({ isVisible = true }) => {
{/* Interface shortcuts */} {/* Interface shortcuts */}
<Card variant='soft' sx={{ p: 2 }}> <Card variant='soft' sx={{ p: 2 }}>
<Typography level='title-sm' sx={{ mb: 1.5, color: 'warning.600' }}> <Typography level='title-sm' sx={{ mb: 1.5, color: 'warning.600' }}>
Interface {t('multiSelect.interface')}
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem <ShortcutItem
keys={['Ctrl', 'K']} keys={['Ctrl', 'K']}
description='Quick add new task' description={t('multiSelect.quickAdd')}
/> />
</Box> </Box>
</Card> </Card>

View File

@@ -73,10 +73,12 @@ import {
import NotificationAccessSnackbar from './NotificationAccessSnackbar' import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel' import Sidepanel from './Sidepanel'
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard' import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
import { useTranslation } from 'react-i18next'
const MyChores = () => { const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } = const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile() useUserProfile()
const { t } = useTranslation('chores')
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md')) const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const { showError, showSuccess, showUndo, showWarning } = useNotification() const { showError, showSuccess, showUndo, showWarning } = useNotification()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -192,16 +194,16 @@ const MyChores = () => {
() => [ () => [
{ {
id: 'status', id: 'status',
label: 'Due Date', label: t('group.dueDate'),
type: 'single-select', type: 'single-select',
icon: <CalendarMonth />, icon: <CalendarMonth />,
options: [ options: [
{ value: 'Overdue', label: 'Overdue', color: 'danger' }, { value: 'Overdue', label: t('group.overdue'), color: 'danger' },
{ value: 'Due today', label: 'Due Today', color: 'warning' }, { value: 'Due today', label: t('group.dueToday'), color: 'warning' },
{ value: 'Due in week', label: 'Due This Week' }, { value: 'Due in week', label: t('group.dueThisWeek') },
{ value: 'Due Later', label: 'Due Later' }, { value: 'Due Later', label: t('group.dueLater') },
{ value: 'No Due Date', label: 'No Due Date' }, { value: 'No Due Date', label: t('group.noDueDate') },
{ value: 'Pending Approval', label: 'Pending Approval' }, { value: 'Pending Approval', label: t('group.pendingApproval') },
], ],
filterFn: (item, value) => { filterFn: (item, value) => {
const now = new Date() const now = new Date()
@@ -232,7 +234,7 @@ const MyChores = () => {
}, },
{ {
id: 'priority', id: 'priority',
label: 'Priority', label: t('priority'),
type: 'multi-select', type: 'multi-select',
icon: <PriorityHigh />, icon: <PriorityHigh />,
options: Priorities.map(p => ({ options: Priorities.map(p => ({
@@ -247,7 +249,7 @@ const MyChores = () => {
? [ ? [
{ {
id: 'label', id: 'label',
label: 'Labels', label: t('labels.label'),
type: 'multi-select', type: 'multi-select',
icon: <Style />, icon: <Style />,
options: userLabels.map(l => ({ options: userLabels.map(l => ({
@@ -977,8 +979,8 @@ const MyChores = () => {
updateFilter={updateFilter} updateFilter={updateFilter}
onFilterSaved={name => onFilterSaved={name =>
showSuccess({ showSuccess({
title: 'Filter Saved', title: t('list.filterSaved'),
message: `"${name}" has been saved`, message: t('list.filterSavedMsg', { name }),
}) })
} }
onClearAllFilters={() => { onClearAllFilters={() => {
@@ -1329,7 +1331,7 @@ const MyChores = () => {
<EmptyState <EmptyState
size='sm' size='sm'
icon={<EditCalendar />} icon={<EditCalendar />}
title='Nothing scheduled' title={t('list.nothingScheduled')}
description='This day is free. Add a task if you want something to land here.' description='This day is free. Add a task if you want something to land here.'
primaryAction={{ primaryAction={{
label: 'Add task', label: 'Add task',
@@ -1471,7 +1473,7 @@ const MyChores = () => {
onClick={() => { onClick={() => {
Navigate(`/chores/create`) Navigate(`/chores/create`)
}} }}
title='Create new chore (Cmd+C)' title={t('list.createChore')}
> >
<Add /> <Add />
<KeyboardShortcutHint <KeyboardShortcutHint
@@ -1581,15 +1583,15 @@ const MyChores = () => {
operator: filter.operator, operator: filter.operator,
}) })
showSuccess({ showSuccess({
title: 'Filter Updated', title: t('list.filterUpdated'),
message: `"${filter.name}" has been updated successfully`, message: t('list.filterUpdatedMsg', { name: filter.name }),
}) })
} else { } else {
// Create new filter // Create new filter
saveFilter(filter) saveFilter(filter)
showSuccess({ showSuccess({
title: 'Advanced Filter Created', title: t('list.advancedFilterCreated'),
message: `"${filter.name}" has been created successfully`, message: t('list.advancedFilterCreatedMsg', { name: filter.name }),
}) })
} }
setShowAdvancedFilterBuilder(false) setShowAdvancedFilterBuilder(false)

View File

@@ -3,9 +3,11 @@ import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import { Button, Snackbar, Stack, Typography } from '@mui/joy' import { Button, Snackbar, Stack, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { registerPushNotifications } from '../../CapacitorListener' import { registerPushNotifications } from '../../CapacitorListener'
const NotificationAccessSnackbar = () => { const NotificationAccessSnackbar = () => {
const { t } = useTranslation('chores')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// Define the function outside of useEffect // Define the function outside of useEffect
@@ -55,10 +57,9 @@ const NotificationAccessSnackbar = () => {
})} })}
> >
<div> <div>
<Typography level='title-lg'>Need Notification?</Typography> <Typography level='title-lg'>{t('notifications.needTitle')}</Typography>
<Typography sx={{ mt: 1, mb: 2 }}> <Typography sx={{ mt: 1, mb: 2 }}>
You need to enable permission to receive notifications, do you want to {t('notifications.needBody')}
enable it?
</Typography> </Typography>
<Stack direction='row' spacing={1}> <Stack direction='row' spacing={1}>
<Button <Button
@@ -84,7 +85,7 @@ const NotificationAccessSnackbar = () => {
setOpen(false) setOpen(false)
}} }}
> >
Yes {t('common:yes')}
</Button> </Button>
<Button <Button
variant='outlined' variant='outlined'
@@ -98,7 +99,7 @@ const NotificationAccessSnackbar = () => {
setOpen(false) setOpen(false)
}} }}
> >
No, Keep it Disabled {t('notifications.keepDisabled')}
</Button> </Button>
</Stack> </Stack>
</div> </div>

View File

@@ -12,6 +12,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import IconButton from '@mui/joy/IconButton' import IconButton from '@mui/joy/IconButton'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
const SortAndGrouping = ({ const SortAndGrouping = ({
@@ -28,6 +29,7 @@ const SortAndGrouping = ({
title, title,
onCreateNewFilter, onCreateNewFilter,
}) => { }) => {
const { t } = useTranslation('chores')
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false) const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false)
@@ -79,12 +81,12 @@ const SortAndGrouping = ({
if (!anchorEl) return if (!anchorEl) return
const groupByItems = [ const groupByItems = [
{ name: 'Smart', value: 'default' }, { name: t('sort.smart'), value: 'default' },
{ name: 'Due Date', value: 'due_date' }, { name: t('group.dueDate'), value: 'due_date' },
{ name: 'Priority', value: 'priority' }, { name: t('priority'), value: 'priority' },
{ name: 'Labels', value: 'labels' }, { name: t('labels.label'), value: 'labels' },
{ name: 'Created Date', value: 'created_date' }, { name: t('sort.createdDate'), value: 'created_date' },
{ name: 'Updated Date', value: 'updated_date' }, { name: t('sort.updatedDate'), value: 'updated_date' },
] ]
const filterItems = [ const filterItems = [
@@ -249,7 +251,7 @@ const SortAndGrouping = ({
height: 24, height: 24,
borderRadius: 24, borderRadius: 24,
}} }}
title='Sort and Group (Ctrl+G)' title={t('sort.sortAndGroup')}
> >
{icon} {icon}
{label ? label : null} {label ? label : null}
@@ -279,7 +281,7 @@ const SortAndGrouping = ({
height: 24, height: 24,
borderRadius: 24, borderRadius: 24,
}} }}
title='Sort and Group (Ctrl+G)' title={t('sort.sortAndGroup')}
> >
{label} {label}
</Button> </Button>
@@ -322,7 +324,7 @@ const SortAndGrouping = ({
> >
<ListItemContent> <ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}> <Typography level='title-sm' sx={{ fontWeight: 600 }}>
{title || 'Group By'} {title || t('sort.groupBy')}
</Typography> </Typography>
</ListItemContent> </ListItemContent>
</MenuItem> </MenuItem>
@@ -330,12 +332,12 @@ const SortAndGrouping = ({
<Divider sx={{ my: 1 }} /> <Divider sx={{ my: 1 }} />
{[ {[
{ name: 'Smart', value: 'default' }, { name: t('sort.smart'), value: 'default' },
{ name: 'Due Date', value: 'due_date' }, { name: t('group.dueDate'), value: 'due_date' },
{ name: 'Priority', value: 'priority' }, { name: t('priority'), value: 'priority' },
{ name: 'Labels', value: 'labels' }, { name: t('labels.label'), value: 'labels' },
{ name: 'Created Date', value: 'created_date' }, { name: t('sort.createdDate'), value: 'created_date' },
{ name: 'Updated Date', value: 'updated_date' }, { name: t('sort.updatedDate'), value: 'updated_date' },
].map((item, index) => ( ].map((item, index) => (
<MenuItem <MenuItem
key={`${k}-${item?.value}`} key={`${k}-${item?.value}`}
@@ -406,7 +408,7 @@ const SortAndGrouping = ({
> >
<ListItemContent> <ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}> <Typography level='title-sm' sx={{ fontWeight: 600 }}>
Quick Filters {t('sort.quickFilters')}
</Typography> </Typography>
</ListItemContent> </ListItemContent>
</MenuItem> </MenuItem>
@@ -422,7 +424,7 @@ const SortAndGrouping = ({
> >
<ListItemContent> <ListItemContent>
<Typography level='body-xs' sx={{ fontWeight: 600 }}> <Typography level='body-xs' sx={{ fontWeight: 600 }}>
Assigned to: {t('sort.assignedTo')}
</Typography> </Typography>
</ListItemContent> </ListItemContent>
</MenuItem> </MenuItem>
@@ -431,28 +433,28 @@ const SortAndGrouping = ({
key={`${k}-assignee-anyone`} key={`${k}-assignee-anyone`}
index={4} index={4}
filterKey='anyone' filterKey='anyone'
label='Anyone' label={t('assignee.anyone')}
/> />
<MenuItem_QuickFilter <MenuItem_QuickFilter
key={`${k}-assignee-assigned-to-me`} key={`${k}-assignee-assigned-to-me`}
index={5} index={5}
filterKey='assigned_to_me' filterKey='assigned_to_me'
label='Assigned to me' label={t('sort.assignedToMe')}
/> />
<MenuItem_QuickFilter <MenuItem_QuickFilter
key={`${k}-assignee-available-for-me`} key={`${k}-assignee-available-for-me`}
index={6} index={6}
filterKey='available_for_me' filterKey='available_for_me'
label='Available for me' label={t('sort.availableForMe')}
/> />
<MenuItem_QuickFilter <MenuItem_QuickFilter
key={`${k}-assignee-assigned-to-others`} key={`${k}-assignee-assigned-to-others`}
index={7} index={7}
filterKey='assigned_to_others' filterKey='assigned_to_others'
label='Assigned to others' label={t('sort.assignedToOthers')}
/> />
<Divider sx={{ my: 1 }} /> <Divider sx={{ my: 1 }} />
@@ -485,13 +487,13 @@ const SortAndGrouping = ({
fontWeight: 500, fontWeight: 500,
}} }}
> >
Create Filter {t('sort.createFilter')}
</Typography> </Typography>
<Typography <Typography
level='body-xs' level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }} sx={{ color: 'var(--joy-palette-text-tertiary)' }}
> >
Build advanced filter rules {t('sort.createFilterHint')}
</Typography> </Typography>
</ListItemContent> </ListItemContent>
</MenuItem> </MenuItem>

View File

@@ -5,8 +5,10 @@ import EmptyState from '../../components/common/EmptyState'
import { useCircleMembers } from '../../queries/UserQueries' import { useCircleMembers } from '../../queries/UserQueries'
import { TASK_COLOR } from '../../utils/Colors' import { TASK_COLOR } from '../../utils/Colors'
import { resolvePhotoURL } from '../../utils/Helpers' import { resolvePhotoURL } from '../../utils/Helpers'
import { useTranslation } from 'react-i18next'
const TasksByAssigneeCard = ({ chores = [] }) => { const TasksByAssigneeCard = ({ chores = [] }) => {
const { t } = useTranslation('chores')
const [assigneeData, setAssigneeData] = useState([]) const [assigneeData, setAssigneeData] = useState([])
const { data: circleMembersData, isLoading: isCircleMembersLoading } = const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers() useCircleMembers()
@@ -105,7 +107,7 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
}} }}
> >
<Typography level='body-sm' color='neutral'> <Typography level='body-sm' color='neutral'>
Loading tasks by assignee... {t('assigneeCard.loading')}
</Typography> </Typography>
</Sheet> </Sheet>
) )
@@ -164,7 +166,7 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
}} }}
> >
<BarChart color='' /> <BarChart color='' />
<Typography level='title-md'>Tasks by Assignee</Typography> <Typography level='title-md'>{t('assigneeCard.title')}</Typography>
</Box> </Box>
</Box> </Box>
@@ -181,22 +183,22 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
{[ {[
{ {
key: 'inProgress', key: 'inProgress',
label: 'In Progress', label: t('filter.status.inProgress'),
color: getStatusColor('inProgress'), color: getStatusColor('inProgress'),
}, },
{ {
key: 'overdue', key: 'overdue',
label: 'Overdue', label: t('group.overdue'),
color: getStatusColor('overdue'), color: getStatusColor('overdue'),
}, },
{ {
key: 'scheduled', key: 'scheduled',
label: 'Scheduled', label: t('assigneeCard.scheduled'),
color: getStatusColor('scheduled'), color: getStatusColor('scheduled'),
}, },
{ {
key: 'pendingReview', key: 'pendingReview',
label: 'Pending Review', label: t('assigneeCard.pendingReview'),
color: getStatusColor('pendingReview'), color: getStatusColor('pendingReview'),
}, },
].map(status => ( ].map(status => (

View File

@@ -2,11 +2,13 @@ import { SupervisorAccount } from '@mui/icons-material'
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy' import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import UserModal from '../Modals/Inputs/UserModal' import UserModal from '../Modals/Inputs/UserModal'
const UserSwitcher = () => { const UserSwitcher = () => {
const { const { t } = useTranslation('chores')
const {
impersonatedUser, impersonatedUser,
isImpersonating, isImpersonating,
startImpersonation, startImpersonation,
@@ -52,15 +54,15 @@ const UserSwitcher = () => {
}} }}
> >
<SupervisorAccount color='' /> <SupervisorAccount color='' />
<Typography level='title-md'>View tasks as</Typography> <Typography level='title-md'>{t('impersonate.viewAs')}</Typography>
</Box> </Box>
</Box> </Box>
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Typography level='title-md' sx={{ mb: 0.5 }}> <Typography level='title-md' sx={{ mb: 0.5 }}>
Switch to user view {t('impersonate.switchTitle')}
</Typography> </Typography>
<Typography level='body-sm' sx={{ mb: 1, color: 'text.secondary' }}> <Typography level='body-sm' sx={{ mb: 1, color: 'text.secondary' }}>
Tasks will be filtered to show only assignments for selected user {t('impersonate.switchHint')}
</Typography> </Typography>
</Box> </Box>
<Button <Button
@@ -69,7 +71,7 @@ const UserSwitcher = () => {
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
size='sm' size='sm'
> >
Choose User {t('impersonate.chooseUser')}
</Button> </Button>
<UserModal <UserModal
isOpen={isModalOpen} isOpen={isModalOpen}
@@ -120,7 +122,7 @@ const UserSwitcher = () => {
}} }}
> >
<SupervisorAccount color='' /> <SupervisorAccount color='' />
<Typography level='title-md'>View tasks as</Typography> <Typography level='title-md'>{t('impersonate.viewAs')}</Typography>
</Box> </Box>
</Box> </Box>
@@ -153,7 +155,7 @@ const UserSwitcher = () => {
setIsModalOpen(true) setIsModalOpen(true)
}} }}
> >
Change User {t('impersonate.changeUser')}
</Button> </Button>
<Button <Button
variant='plain' variant='plain'
@@ -164,7 +166,7 @@ const UserSwitcher = () => {
stopImpersonation() stopImpersonation()
}} }}
> >
Cancel {t('common:cancel')}
</Button> </Button>
</Box> </Box>
</Box> </Box>

View File

@@ -1,4 +1,5 @@
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { useTranslation } from 'react-i18next'
import DateModal from '../../Modals/Inputs/DateModal' import DateModal from '../../Modals/Inputs/DateModal'
import DueDatePickerModal, { import DueDatePickerModal, {
combineDueDate, combineDueDate,
@@ -25,13 +26,14 @@ const ChoreModals = ({
onNudge, onNudge,
onClose, onClose,
}) => { }) => {
const { t } = useTranslation('chores')
return ( return (
<> <>
{activeModal === 'changeDueDate' && modalChore && ( {activeModal === 'changeDueDate' && modalChore && (
<DueDatePickerModal <DueDatePickerModal
open={true} open={true}
key={'changeDueDate' + modalChore.id} key={'changeDueDate' + modalChore.id}
title='Change due date' title={t('modals.changeDueDate')}
{...splitDueDate(modalChore.nextDueDate)} {...splitDueDate(modalChore.nextDueDate)}
onClose={onClose} onClose={onClose}
onApply={parts => onApply={parts =>
@@ -46,7 +48,7 @@ const ChoreModals = ({
isOpen={true} isOpen={true}
key={'completedInPast' + modalChore.id} key={'completedInPast' + modalChore.id}
current={modalChore.nextDueDate} current={modalChore.nextDueDate}
title='Save Chore that you completed in the past' title={t('modals.completePast')}
onClose={onClose} onClose={onClose}
onSave={onCompleteWithPastDate} onSave={onCompleteWithPastDate}
/> />
@@ -57,8 +59,8 @@ const ChoreModals = ({
isOpen={true} isOpen={true}
options={membersData?.res || []} options={membersData?.res || []}
displayKey='displayName' displayKey='displayName'
title='Delegate to someone else' title={t('modals.delegate')}
placeholder='Select a performer' placeholder={t('modals.selectPerformer')}
onClose={onClose} onClose={onClose}
onSave={selected => onAssigneeChange(selected.id)} onSave={selected => onAssigneeChange(selected.id)}
/> />
@@ -67,9 +69,9 @@ const ChoreModals = ({
{activeModal === 'completeWithNote' && modalChore && ( {activeModal === 'completeWithNote' && modalChore && (
<TextModal <TextModal
isOpen={true} isOpen={true}
title='Add note to attach to this completion:' title={t('modals.addNote')}
onClose={onClose} onClose={onClose}
okText='Complete' okText={t('modals.complete')}
onSave={onCompleteWithNote} onSave={onCompleteWithNote}
/> />
)} )}

View File

@@ -7,6 +7,7 @@ import {
Star, Star,
StarBorder, StarBorder,
} from '@mui/icons-material' } from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Chip, Chip,
@@ -28,6 +29,7 @@ const CustomFilterChips = ({
onFilterPin, onFilterPin,
onFilterEdit, onFilterEdit,
}) => { }) => {
const { t } = useTranslation('chores')
const navigate = useNavigate() const navigate = useNavigate()
const [menuAnchor, setMenuAnchor] = useState(null) const [menuAnchor, setMenuAnchor] = useState(null)
const [selectedFilter, setSelectedFilter] = useState(null) const [selectedFilter, setSelectedFilter] = useState(null)
@@ -237,24 +239,24 @@ const CustomFilterChips = ({
{selectedFilter.isPinned ? ( {selectedFilter.isPinned ? (
<> <>
<StarBorder sx={{ mr: 1 }} /> <StarBorder sx={{ mr: 1 }} />
Unpin filter {t('filterChip.unpin')}
</> </>
) : ( ) : (
<> <>
<Star sx={{ mr: 1 }} /> <Star sx={{ mr: 1 }} />
Pin filter {t('filterChip.pin')}
</> </>
)} )}
</MenuItem> </MenuItem>
{onFilterEdit && ( {onFilterEdit && (
<MenuItem onClick={handleEdit}> <MenuItem onClick={handleEdit}>
<Edit sx={{ mr: 1 }} /> <Edit sx={{ mr: 1 }} />
Edit filter {t('filterChip.edit')}
</MenuItem> </MenuItem>
)} )}
<MenuItem onClick={handleDelete} color='danger'> <MenuItem onClick={handleDelete} color='danger'>
<Delete sx={{ mr: 1 }} /> <Delete sx={{ mr: 1 }} />
Delete filter {t('filterChip.delete')}
</MenuItem> </MenuItem>
</> </>
)} )}

View File

@@ -21,6 +21,7 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import LABEL_COLORS, { import LABEL_COLORS, {
getTextColorFromBackgroundColor, getTextColorFromBackgroundColor,
@@ -53,6 +54,7 @@ const MultiSelectToolbar = ({
showKeyboardShortcuts, showKeyboardShortcuts,
selectAllDisabled, selectAllDisabled,
}) => { }) => {
const { t } = useTranslation('chores')
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null) const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
const projectMenuRef = useRef(null) const projectMenuRef = useRef(null)
@@ -120,7 +122,7 @@ const MultiSelectToolbar = ({
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckBox sx={{ color: 'primary.500' }} /> <CheckBox sx={{ color: 'primary.500' }} />
<Typography level='body-sm' fontWeight='md'> <Typography level='body-sm' fontWeight='md'>
{selectedCount} task{selectedCount !== 1 ? 's' : ''} selected {t('archived.selected', { count: selectedCount })}
</Typography> </Typography>
</Box> </Box>
@@ -143,9 +145,9 @@ const MultiSelectToolbar = ({
'--Button-paddingInline': '0.75rem', '--Button-paddingInline': '0.75rem',
position: 'relative', position: 'relative',
}} }}
title='Select all visible tasks (Ctrl+A)' title={t('archived.selectAllTitle')}
> >
All {t('archived.all')}
{showKeyboardShortcuts && ( {showKeyboardShortcuts && (
<KeyboardShortcutHint <KeyboardShortcutHint
shortcut='A' shortcut='A'
@@ -170,9 +172,13 @@ const MultiSelectToolbar = ({
'--Button-paddingInline': '0.75rem', '--Button-paddingInline': '0.75rem',
position: 'relative', position: 'relative',
}} }}
title={`${selectedCount === 0 ? 'Close' : 'Clear'} multi-select (Esc)`} title={
selectedCount === 0
? t('archived.closeMultiSelect')
: t('archived.clearMultiSelect')
}
> >
{selectedCount === 0 ? 'Close' : 'Clear'} {selectedCount === 0 ? t('archived.close') : t('archived.clear')}
{showKeyboardShortcuts && ( {showKeyboardShortcuts && (
<KeyboardShortcutHint <KeyboardShortcutHint
withCtrl={false} withCtrl={false}
@@ -215,9 +221,9 @@ const MultiSelectToolbar = ({
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative', position: 'relative',
}} }}
title='Complete selected tasks (Enter)' title={t('multiToolbar.completeTitle')}
> >
Complete {t('multiToolbar.complete')}
{showKeyboardShortcuts && selectedCount > 0 && ( {showKeyboardShortcuts && selectedCount > 0 && (
<KeyboardShortcutHint <KeyboardShortcutHint
shortcut='Enter' shortcut='Enter'
@@ -241,9 +247,9 @@ const MultiSelectToolbar = ({
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative', position: 'relative',
}} }}
title='Skip selected tasks (/)' title={t('multiToolbar.skipTitle')}
> >
Skip {t('multiToolbar.skip')}
{showKeyboardShortcuts && selectedCount > 0 && ( {showKeyboardShortcuts && selectedCount > 0 && (
<KeyboardShortcutHint <KeyboardShortcutHint
shortcut='/' shortcut='/'
@@ -324,9 +330,9 @@ const MultiSelectToolbar = ({
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative', position: 'relative',
}} }}
title='Archive selected tasks (X)' title={t('multiToolbar.archiveTitle')}
> >
Archive {t('multiToolbar.archive')}
{showKeyboardShortcuts && selectedCount > 0 && ( {showKeyboardShortcuts && selectedCount > 0 && (
<KeyboardShortcutHint <KeyboardShortcutHint
shortcut='X' shortcut='X'
@@ -351,9 +357,9 @@ const MultiSelectToolbar = ({
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative', position: 'relative',
}} }}
title='Delete selected tasks (Shift+X)' title={t('multiToolbar.deleteTitle')}
> >
Delete {t('archived.delete')}
{showKeyboardShortcuts && selectedCount > 0 && ( {showKeyboardShortcuts && selectedCount > 0 && (
<KeyboardShortcutHint <KeyboardShortcutHint
shortcut='E' shortcut='E'

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export const useKeyboardShortcuts = ({ export const useKeyboardShortcuts = ({
addTaskModalOpen, addTaskModalOpen,
@@ -11,6 +12,7 @@ export const useKeyboardShortcuts = ({
searchTerm, searchTerm,
selectedChores, selectedChores,
}) => { }) => {
const { t } = useTranslation('chores')
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
useEffect(() => { useEffect(() => {
@@ -64,13 +66,13 @@ export const useKeyboardShortcuts = ({
if (allVisibleSelected) { if (allVisibleSelected) {
handlers.onShowMessage({ handlers.onShowMessage({
title: '✅ All Tasks Selected', title: t('shortcuts.allSelectedTitle'),
message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`, message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`,
}) })
} else { } else {
handlers.onSelectAll() handlers.onSelectAll()
handlers.onShowMessage({ handlers.onShowMessage({
title: '🎯 Tasks Selected', title: t('shortcuts.someSelectedTitle'),
message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`, message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`,
}) })
} }
@@ -92,20 +94,20 @@ export const useKeyboardShortcuts = ({
if (allChoresSelected) { if (allChoresSelected) {
handlers.onShowMessage({ handlers.onShowMessage({
title: '✅ All Tasks Selected', title: t('shortcuts.allSelectedTitle'),
message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`, message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`,
}) })
} else if (allExpandedSelected) { } else if (allExpandedSelected) {
handlers.onSelectAll() handlers.onSelectAll()
const collapsedCount = allChores.length - expandedChores.length const collapsedCount = allChores.length - expandedChores.length
handlers.onShowMessage({ handlers.onShowMessage({
title: '🎯 All Tasks Selected', title: t('shortcuts.allSelectedAltTitle'),
message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`, message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`,
}) })
} else { } else {
handlers.onSelectAll() handlers.onSelectAll()
handlers.onShowMessage({ handlers.onShowMessage({
title: '🎯 Tasks Selected', title: t('shortcuts.someSelectedTitle'),
message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`, message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`,
}) })
} }
@@ -185,7 +187,7 @@ export const useKeyboardShortcuts = ({
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp) document.removeEventListener('keyup', handleKeyUp)
} }
}, [isMultiSelectMode, selectedChores.size, addTaskModalOpen]) }, [isMultiSelectMode, selectedChores.size, addTaskModalOpen, t])
return { showKeyboardShortcuts } return { showKeyboardShortcuts }
} }

View File

@@ -9,6 +9,7 @@ import {
SearchRounded, SearchRounded,
Warning, Warning,
} from '@mui/icons-material' } from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
import { import {
Avatar, Avatar,
Button, Button,
@@ -32,6 +33,7 @@ import DateModal from './Modals/Inputs/DateModal'
// enum for chore status: // enum for chore status:
const CHORE_STATUS = { const CHORE_STATUS = {
const { t } = useTranslation('chores')
NO_DUE_DATE: 'No due date', NO_DUE_DATE: 'No due date',
DUE_SOON: 'Soon', DUE_SOON: 'Soon',
DUE_NOW: 'Due', DUE_NOW: 'Due',
@@ -119,7 +121,7 @@ const ChoresOverview = () => {
return ( return (
<Container> <Container>
<Typography level='h4' mb={1.5}> <Typography level='h4' mb={1.5}>
Chores Overviews {t('overview.title')}
</Typography> </Typography>
{/* <SummaryCard /> */} {/* <SummaryCard /> */}
<Grid container> <Grid container>
@@ -132,7 +134,7 @@ const ChoresOverview = () => {
gap={2} gap={2}
> >
<Input <Input
placeholder='Search' placeholder={t('overview.search')}
value={search} value={search}
onChange={e => { onChange={e => {
if (e.target.value === '') { if (e.target.value === '') {
@@ -169,7 +171,7 @@ const ChoresOverview = () => {
Navigate(`/chores/create`) Navigate(`/chores/create`)
}} }}
> >
New Chore {t('overview.newChore')}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>
@@ -228,7 +230,7 @@ const ChoresOverview = () => {
color='warning' color='warning'
startDecorator={<Avatar color='primary'>?</Avatar>} startDecorator={<Avatar color='primary'>?</Avatar>}
> >
Unassigned {t('overview.unassigned')}
</Chip> </Chip>
)} )}
</td> </td>
@@ -315,7 +317,7 @@ const ChoresOverview = () => {
<DateModal <DateModal
isOpen={isDateModalOpen} isOpen={isDateModalOpen}
key={choreId} key={choreId}
title={`Change due date`} title={t('overview.changeDueDate')}
onClose={() => { onClose={() => {
setIsDateModalOpen(false) setIsDateModalOpen(false)
}} }}

View File

@@ -15,6 +15,7 @@ import { GetChoreAttachments } from '../../../utils/Fetcher'
import { resolvePhotoURL } from '../../../utils/Helpers' import { resolvePhotoURL } from '../../../utils/Helpers'
import { cacheChoreImages, getImageSrc } from '../../../utils/ImageCache' import { cacheChoreImages, getImageSrc } from '../../../utils/ImageCache'
import AttachmentViewerModal from './AttachmentViewerModal' import AttachmentViewerModal from './AttachmentViewerModal'
import { useTranslation } from 'react-i18next'
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'] const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
@@ -35,6 +36,7 @@ const downloadFile = (url, fileName) => {
} }
function AttachmentBrowserModal({ choreId, isOpen, onClose }) { function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
const [attachments, setAttachments] = useState([]) const [attachments, setAttachments] = useState([])
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -45,7 +47,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
setIsLoading(true) setIsLoading(true)
GetChoreAttachments(choreId) GetChoreAttachments(choreId)
.then(async res => { .then(async res => {
if (!res.ok) throw new Error('Failed to fetch attachments') if (!res.ok) throw new Error(t('chores:dataError.attachmentsFailed'))
return res.json() return res.json()
}) })
.then(data => { .then(data => {
@@ -56,7 +58,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
}) })
.catch(() => setAttachments([])) .catch(() => setAttachments([]))
.finally(() => setIsLoading(false)) .finally(() => setIsLoading(false))
}, [isOpen, choreId]) }, [isOpen, choreId, t])
const handleClose = () => { const handleClose = () => {
setAttachments([]) setAttachments([])
@@ -86,7 +88,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
<ResponsiveModal <ResponsiveModal
open={!!isOpen} open={!!isOpen}
onClose={handleClose} onClose={handleClose}
title='Attachments' title={t('attachments')}
footer={ footer={
<ModalActions primary={{ label: 'Done', onClick: handleClose }} /> <ModalActions primary={{ label: 'Done', onClick: handleClose }} />
} }
@@ -126,7 +128,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
)} )}
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-sm' noWrap> <Typography level='body-sm' noWrap>
{attachment.file_name || `File ${index + 1}`} {attachment.file_name || t('fileNumbered', { index: index + 1 })}
</Typography> </Typography>
{attachment.size_bytes > 0 && ( {attachment.size_bytes > 0 && (
<Typography <Typography

View File

@@ -8,12 +8,14 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions' import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle' import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
function NudgeModal({ config }) { function NudgeModal({ config }) {
const { t } = useTranslation('chores')
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@@ -107,19 +109,19 @@ function NudgeModal({ config }) {
size='lg' size='lg'
fullWidth={true} fullWidth={true}
unmountDelay={250} unmountDelay={250}
title='Send Nudge' title={t('nudge.title')}
description='Send a gentle reminder to the people assigned to this task.' description='Send a gentle reminder to the people assigned to this task.'
footer={ footer={
<ModalActions <ModalActions
secondary={{ secondary={{
label: 'Cancel', label: t('choreView.cancel'),
onClick: () => handleAction(false), onClick: () => handleAction(false),
endDecorator: showKeyboardShortcuts ? ( endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='X' /> <KeyboardShortcutHint shortcut='X' />
) : undefined, ) : undefined,
}} }}
primary={{ primary={{
label: 'Send Nudge', label: t('nudge.title'),
onClick: () => handleAction(true), onClick: () => handleAction(true),
disabled: !isOfficialInstance, disabled: !isOfficialInstance,
endDecorator: showKeyboardShortcuts ? ( endDecorator: showKeyboardShortcuts ? (
@@ -144,9 +146,9 @@ function NudgeModal({ config }) {
)} )}
<FormControl mb={2}> <FormControl mb={2}>
<FormLabel>Custom Message (optional)</FormLabel> <FormLabel>{t('nudge.customMessage')}</FormLabel>
<Textarea <Textarea
placeholder='Add a personal message with your nudge...' placeholder={t('nudge.messagePlaceholder')}
value={message} value={message}
onChange={e => setMessage(e.target.value)} onChange={e => setMessage(e.target.value)}
minRows={3} minRows={3}
@@ -156,10 +158,9 @@ function NudgeModal({ config }) {
<FormControl orientation='horizontal' sx={{ mb: 3 }}> <FormControl orientation='horizontal' sx={{ mb: 3 }}>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<FormLabel>Notify All Assignees</FormLabel> <FormLabel>{t('nudge.notifyAll')}</FormLabel>
<Typography level='body-sm' color='text.secondary'> <Typography level='body-sm' color='text.secondary'>
If enabled, all members who can see this task will be notified. {t('nudge.notifyAllHint')}
Otherwise, only the assigned person will receive the nudge.
</Typography> </Typography>
</Box> </Box>
<Switch <Switch

View File

@@ -10,6 +10,7 @@ import { useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions' import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { startNativeNFCWrite } from '../../../service/NFCWriter' import { startNativeNFCWrite } from '../../../service/NFCWriter'
import { useTranslation } from 'react-i18next'
const pulseKeyframes = ` const pulseKeyframes = `
@keyframes nfc-pulse { @keyframes nfc-pulse {
@@ -105,6 +106,7 @@ function NFCIcon({ status }) {
} }
function WriteNFCModal({ config }) { function WriteNFCModal({ config }) {
const { t } = useTranslation('chores')
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
const [nfcStatus, setNfcStatus] = useState('idle') const [nfcStatus, setNfcStatus] = useState('idle')
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
@@ -171,13 +173,11 @@ function WriteNFCModal({ config }) {
} catch (error) { } catch (error) {
console.error('Error writing to NFC tag:', error) console.error('Error writing to NFC tag:', error)
setNfcStatus('error') setNfcStatus('error')
setErrorMessage('Error writing to NFC tag. Please try again.') setErrorMessage(t('nfc.errWrite'))
} }
} else { } else {
setNfcStatus('error') setNfcStatus('error')
setErrorMessage( setErrorMessage(t('nfc.errUnsupported'))
'NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.',
)
} }
} }
} }
@@ -187,20 +187,20 @@ function WriteNFCModal({ config }) {
const isError = nfcStatus === 'error' const isError = nfcStatus === 'error'
const title = isSuccess const title = isSuccess
? 'Tag written!' ? t('nfc.titleSuccess')
: isError : isError
? 'Something went wrong' ? t('nfc.titleError')
: isWaiting : isWaiting
? 'Hold near NFC tag' ? t('nfc.titleWaiting')
: 'Write to NFC' : t('actionMenu.writeNFC')
const subtitle = isSuccess const subtitle = isSuccess
? 'Your NFC tag is ready to use.' ? t('nfc.subSuccess')
: isError : isError
? errorMessage ? errorMessage
: isWaiting : isWaiting
? 'Keep your device near the tag until complete.' ? t('nfc.subWaiting')
: 'Encode this task link onto any NFC tag.' : t('nfc.subIdle')
return ( return (
<> <>
@@ -214,14 +214,14 @@ function WriteNFCModal({ config }) {
closeOnEscape={!isWaiting} closeOnEscape={!isWaiting}
footer={ footer={
isSuccess ? ( isSuccess ? (
<ModalActions primary={{ label: 'Done', onClick: handleClose }} /> <ModalActions primary={{ label: t('activity.status.done'), onClick: handleClose }} />
) : isWaiting ? ( ) : isWaiting ? (
<ModalActions <ModalActions
secondary={{ label: 'Cancel', onClick: handleCancel }} secondary={{ label: t('choreView.cancel'), onClick: handleCancel }}
/> />
) : ( ) : (
<ModalActions <ModalActions
secondary={{ label: 'Cancel', onClick: handleClose }} secondary={{ label: t('choreView.cancel'), onClick: handleClose }}
primary={{ primary={{
label: nfcStatus === 'writing' ? 'Starting…' : 'Write tag', label: nfcStatus === 'writing' ? 'Starting…' : 'Write tag',
onClick: writeToNFC, onClick: writeToNFC,
@@ -250,7 +250,7 @@ function WriteNFCModal({ config }) {
color: 'text.tertiary', color: 'text.tertiary',
}} }}
> >
Tag URL {t('nfc.tagUrl')}
</Typography> </Typography>
<Input <Input
value={getURL()} value={getURL()}
@@ -269,7 +269,7 @@ function WriteNFCModal({ config }) {
variant='plain' variant='plain'
color={copied ? 'success' : 'neutral'} color={copied ? 'success' : 'neutral'}
onClick={handleCopy} onClick={handleCopy}
title='Copy URL' title={t('nfc.copyUrl')}
> >
<ContentCopy sx={{ fontSize: 16 }} /> <ContentCopy sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
@@ -280,7 +280,7 @@ function WriteNFCModal({ config }) {
level='body-xs' level='body-xs'
sx={{ color: 'success.500', mt: 0.5, textAlign: 'right' }} sx={{ color: 'success.500', mt: 0.5, textAlign: 'right' }}
> >
Copied! {t('common:copied')}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -299,10 +299,10 @@ function WriteNFCModal({ config }) {
> >
<Box> <Box>
<Typography level='body-sm' fontWeight={500}> <Typography level='body-sm' fontWeight={500}>
Auto-complete on scan {t('nfc.autoComplete')}
</Typography> </Typography>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}> <Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Mark task done when tag is tapped {t('nfc.autoCompleteHint')}
</Typography> </Typography>
</Box> </Box>
<Switch <Switch

View File

@@ -38,6 +38,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import AppModal from '../../components/common/AppModal' import AppModal from '../../components/common/AppModal'
import LABEL_COLORS, { import LABEL_COLORS, {
@@ -63,6 +64,7 @@ const ChoreActionMenu = ({
sx = {}, sx = {},
variant = 'soft', variant = 'soft',
}) => { }) => {
const { t } = useTranslation('chores')
const [anchorEl, setAnchorEl] = React.useState(null) const [anchorEl, setAnchorEl] = React.useState(null)
const [isOfficialInstance, setIsOfficialInstance] = useState(false) const [isOfficialInstance, setIsOfficialInstance] = useState(false)
const [showProjectPicker, setShowProjectPicker] = useState(false) const [showProjectPicker, setShowProjectPicker] = useState(false)
@@ -250,7 +252,7 @@ const ChoreActionMenu = ({
{ {
key: 'completeNote', key: 'completeNote',
icon: <NoteAdd />, icon: <NoteAdd />,
label: 'Complete with note', label: t('actionMenu.completeWithNote'),
onClick: () => { onClick: () => {
onCompleteWithNote?.() onCompleteWithNote?.()
handleMenuClose() handleMenuClose()
@@ -259,7 +261,7 @@ const ChoreActionMenu = ({
{ {
key: 'completePast', key: 'completePast',
icon: <Update />, icon: <Update />,
label: 'Complete in past', label: t('actionMenu.completeInPast'),
onClick: () => { onClick: () => {
onCompleteWithPastDate?.() onCompleteWithPastDate?.()
handleMenuClose() handleMenuClose()
@@ -268,13 +270,13 @@ const ChoreActionMenu = ({
{ {
key: 'skip', key: 'skip',
icon: <SwitchAccessShortcut />, icon: <SwitchAccessShortcut />,
label: 'Skip to next due date', label: t('actionMenu.skipToNext'),
onClick: handleSkip, onClick: handleSkip,
}, },
{ {
key: 'delegate', key: 'delegate',
icon: <RecordVoiceOver />, icon: <RecordVoiceOver />,
label: 'Delegate to someone else', label: t('modals.delegate'),
onClick: () => { onClick: () => {
onChangeAssignee?.() onChangeAssignee?.()
handleMenuClose() handleMenuClose()
@@ -283,7 +285,7 @@ const ChoreActionMenu = ({
isOfficialInstance && { isOfficialInstance && {
key: 'nudge', key: 'nudge',
icon: <Notifications />, icon: <Notifications />,
label: 'Send nudge', label: t('actionMenu.sendNudge'),
onClick: () => { onClick: () => {
onNudge?.() onNudge?.()
handleMenuClose() handleMenuClose()
@@ -293,7 +295,7 @@ const ChoreActionMenu = ({
{ {
key: 'history', key: 'history',
icon: <ManageSearch />, icon: <ManageSearch />,
label: 'History', label: t('actionMenu.history'),
onClick: handleHistory, onClick: handleHistory,
}, },
{ key: 'divider-2', type: 'divider' }, { key: 'divider-2', type: 'divider' },
@@ -302,7 +304,7 @@ const ChoreActionMenu = ({
{ {
key: 'changeDueDate', key: 'changeDueDate',
icon: <MoreTime />, icon: <MoreTime />,
label: 'Change due date', label: t('modals.changeDueDate'),
onClick: () => { onClick: () => {
onChangeDueDate?.() onChangeDueDate?.()
handleMenuClose() handleMenuClose()
@@ -311,15 +313,15 @@ const ChoreActionMenu = ({
{ {
key: 'writeNfc', key: 'writeNfc',
icon: <Nfc />, icon: <Nfc />,
label: 'Write to NFC', label: t('actionMenu.writeNFC'),
onClick: () => { onClick: () => {
onWriteNFC?.() onWriteNFC?.()
handleMenuClose() handleMenuClose()
}, },
}, },
{ key: 'edit', icon: <Edit />, label: 'Edit', onClick: handleEdit }, { key: 'edit', icon: <Edit />, label: t('choreView.edit'), onClick: handleEdit },
{ key: 'clone', icon: <CopyAll />, label: 'Clone', onClick: handleClone }, { key: 'clone', icon: <CopyAll />, label: t('actionMenu.clone'), onClick: handleClone },
{ key: 'view', icon: <ViewCarousel />, label: 'View', onClick: handleView }, { key: 'view', icon: <ViewCarousel />, label: t('actionMenu.view'), onClick: handleView },
{ {
key: 'archive', key: 'archive',
icon: chore.isActive ? <Archive /> : <Unarchive />, icon: chore.isActive ? <Archive /> : <Unarchive />,
@@ -330,14 +332,14 @@ const ChoreActionMenu = ({
projects.length > 0 && { projects.length > 0 && {
key: 'moveToProject', key: 'moveToProject',
icon: <DriveFileMove />, icon: <DriveFileMove />,
label: 'Move to project', label: t('actionMenu.moveToProject'),
onClick: () => setShowProjectPicker(true), onClick: () => setShowProjectPicker(true),
}, },
{ key: 'divider-4', type: 'divider' }, { key: 'divider-4', type: 'divider' },
{ {
key: 'delete', key: 'delete',
icon: <Delete />, icon: <Delete />,
label: 'Delete', label: t('archived.delete'),
onClick: handleDelete, onClick: handleDelete,
color: 'danger', color: 'danger',
}, },
@@ -345,7 +347,7 @@ const ChoreActionMenu = ({
const quickScheduleButtons = ( const quickScheduleButtons = (
<> <>
<Tooltip title='Today' placement='top'> <Tooltip title={t('duePicker.today')} placement='top'>
<IconButton <IconButton
size='sm' size='sm'
onClick={e => { onClick={e => {
@@ -356,7 +358,7 @@ const ChoreActionMenu = ({
<Today /> <Today />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title='Tomorrow' placement='top'> <Tooltip title={t('duePicker.tomorrow')} placement='top'>
<IconButton <IconButton
size='sm' size='sm'
onClick={e => { onClick={e => {
@@ -367,7 +369,7 @@ const ChoreActionMenu = ({
<WbSunny /> <WbSunny />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title='Weekend' placement='top'> <Tooltip title={t('duePicker.weekend')} placement='top'>
<IconButton <IconButton
size='sm' size='sm'
onClick={e => { onClick={e => {
@@ -378,7 +380,7 @@ const ChoreActionMenu = ({
<Weekend /> <Weekend />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title='Next week' placement='top'> <Tooltip title={t('duePicker.nextWeek')} placement='top'>
<IconButton <IconButton
size='sm' size='sm'
onClick={e => { onClick={e => {
@@ -389,7 +391,7 @@ const ChoreActionMenu = ({
<NextWeek /> <NextWeek />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title='Remove due date' placement='top'> <Tooltip title={t('actionMenu.removeDueDate')} placement='top'>
<IconButton <IconButton
size='sm' size='sm'
color='neutral' color='neutral'
@@ -529,7 +531,7 @@ const ChoreActionMenu = ({
> >
<ArrowBack fontSize='small' /> <ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}> <Typography level='body-sm' fontWeight={600}>
Back {t('common:back')}
</Typography> </Typography>
</MenuItem> </MenuItem>
)} )}

View File

@@ -7,6 +7,7 @@ import {
Replay, Replay,
TextSnippet, TextSnippet,
} from '@mui/icons-material' } from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Button, Button,
@@ -121,6 +122,7 @@ async function extractTaskFromOCR(ocrText) {
} }
const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => { const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
const { t } = useTranslation('chores')
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
const { isNativeScanner, scanDocument } = useDocumentScanner() const { isNativeScanner, scanDocument } = useDocumentScanner()
const videoRef = useRef(null) const videoRef = useRef(null)
@@ -302,7 +304,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
onClose={handleClose} onClose={handleClose}
size='md' size='md'
fullWidth fullWidth
title='Scan photo to create task' title={t('photoTask.title')}
> >
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{(phase === 'capture' || phase === 'preview') && ( {(phase === 'capture' || phase === 'preview') && (
@@ -340,7 +342,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
<Box sx={{ textAlign: 'center', p: 3 }}> <Box sx={{ textAlign: 'center', p: 3 }}>
<CameraAlt sx={{ fontSize: 48, opacity: 0.5, mb: 1 }} /> <CameraAlt sx={{ fontSize: 48, opacity: 0.5, mb: 1 }} />
<Typography level='body-sm' sx={{ opacity: 0.7 }}> <Typography level='body-sm' sx={{ opacity: 0.7 }}>
Camera not available {t('photoTask.cameraUnavailable')}
</Typography> </Typography>
</Box> </Box>
)} )}
@@ -487,7 +489,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
startDecorator={<PhotoCamera />} startDecorator={<PhotoCamera />}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
Upload Photo {t('photoTask.uploadPhoto')}
</Button> </Button>
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -503,7 +505,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
startDecorator={<DocumentScanner />} startDecorator={<DocumentScanner />}
onClick={handleNativeScan} onClick={handleNativeScan}
> >
Scan Document {t('photoTask.scanDocument')}
</Button> </Button>
) : ( ) : (
cameraAvailable && ( cameraAvailable && (
@@ -513,7 +515,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
startDecorator={<CameraAlt />} startDecorator={<CameraAlt />}
onClick={handleCapture} onClick={handleCapture}
> >
Capture {t('photoTask.capture')}
</Button> </Button>
) )
)} )}
@@ -538,7 +540,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
color='primary' color='primary'
onClick={() => handleProcess('native')} onClick={() => handleProcess('native')}
> >
Process Natively {t('photoTask.processNatively')}
</Button> </Button>
)} )}
<Button <Button
@@ -546,7 +548,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
color='primary' color='primary'
onClick={() => handleProcess('tesseract')} onClick={() => handleProcess('tesseract')}
> >
Process Image {t('photoTask.processImage')}
</Button> </Button>
</> </>
)} )}
@@ -559,7 +561,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
startDecorator={<ArrowBack />} startDecorator={<ArrowBack />}
onClick={handleBackToPreview} onClick={handleBackToPreview}
> >
Back {t('common:back')}
</Button> </Button>
<Button <Button
variant='outlined' variant='outlined'
@@ -580,7 +582,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
startDecorator={<ArrowBack />} startDecorator={<ArrowBack />}
onClick={handleBackToPreview} onClick={handleBackToPreview}
> >
Back {t('common:back')}
</Button> </Button>
<Button <Button
variant='outlined' variant='outlined'
@@ -591,7 +593,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
Retake Retake
</Button> </Button>
<Button variant='solid' color='primary' onClick={handleConfirm}> <Button variant='solid' color='primary' onClick={handleConfirm}>
Create Task {t('photoTask.createTask')}
</Button> </Button>
</> </>
)} )}

View File

@@ -46,6 +46,7 @@ import { apiClient } from '../../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers' import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { patchDescriptionHtml } from '../../utils/ImageCache' import { patchDescriptionHtml } from '../../utils/ImageCache'
import './RichTextEditor.css' import './RichTextEditor.css'
import { useTranslation } from 'react-i18next'
const RichTextEditor = forwardRef( const RichTextEditor = forwardRef(
( (
@@ -53,7 +54,7 @@ const RichTextEditor = forwardRef(
value = '', value = '',
onChange, onChange,
isEditable = true, isEditable = true,
placeholder = 'Enter description...', placeholder = null,
variant = 'outlined', variant = 'outlined',
entityId, entityId,
entityType, entityType,
@@ -61,6 +62,7 @@ const RichTextEditor = forwardRef(
}, },
ref, ref,
) => { ) => {
const { t } = useTranslation('chores')
const { showError } = useNotification() const { showError } = useNotification()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
// Display-only HTML with expired image srcs swapped for cached/re-signed ones // Display-only HTML with expired image srcs swapped for cached/re-signed ones
@@ -92,9 +94,9 @@ const RichTextEditor = forwardRef(
// Check if user has plus account // Check if user has plus account
if (!isPlusAccount(userProfile)) { if (!isPlusAccount(userProfile)) {
showError({ showError({
title: 'Plus Feature', title: t('common:upload.plusFeatureTitle'),
message: message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.', t('common:upload.plusFeatureMessage'),
}) })
return return
} }
@@ -155,33 +157,33 @@ const RichTextEditor = forwardRef(
if (response.status === 507) { if (response.status === 507) {
showError({ showError({
title: 'Storage Quota Exceeded', title: t('common:upload.quotaTitle'),
message: 'You have exceeded your quota for uploading files.', message: t('common:upload.quotaMessage'),
}) })
return return
} else if (response.status === 413) { } else if (response.status === 413) {
showError({ showError({
title: 'File Too Large', title: t('common:upload.tooLargeTitle'),
message: 'The file you are trying to upload is too large.', message: t('common:upload.tooLargeMessage'),
}) })
return return
} else if (response.status === 403 && !isPlusAccount(userProfile)) { } else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({ showError({
title: 'Upgrade Required', title: t('common:upload.upgradeTitle'),
message: message:
'Image uploads are only available for Plus accounts. Please ', t('common:upload.upgradeMessage'),
}) })
return return
} else if (response.status === 403) { } else if (response.status === 403) {
showError({ showError({
title: 'Permission Denied', title: t('common:upload.deniedTitle'),
message: 'You do not have permission to upload files.', message: t('common:upload.deniedMessage'),
}) })
return return
} else if (!response.ok) { } else if (!response.ok) {
showError({ showError({
title: 'Upload Failed', title: t('common:upload.failedTitle'),
message: 'Failed to upload image.', message: t('common:upload.failedMessage'),
}) })
return return
} }
@@ -199,8 +201,8 @@ const RichTextEditor = forwardRef(
} catch (error) { } catch (error) {
console.error('Error during image processing or upload:', error) console.error('Error during image processing or upload:', error)
showError({ showError({
title: 'Upload Failed', title: t('common:upload.failedTitle'),
message: 'An error occurred while processing the image.', message: t('common:upload.processingMessage'),
}) })
} }
} }
@@ -226,7 +228,7 @@ const RichTextEditor = forwardRef(
}, },
}, },
}, },
placeholder: placeholder, placeholder: placeholder ?? t('descriptionPlaceholder'),
}) })
new QuillMarkdown(editorRef.current, {}) new QuillMarkdown(editorRef.current, {})
editorRef.current.root.innerHTML = value editorRef.current.root.innerHTML = value

View File

@@ -5,6 +5,7 @@ import {
Replay, Replay,
WarningAmber, WarningAmber,
} from '@mui/icons-material' } from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Button, Button,
@@ -37,6 +38,7 @@ const ScanPanel = ({
onTaskExtracted, onTaskExtracted,
open, open,
}) => { }) => {
const { t } = useTranslation('chores')
const { const {
activate, activate,
cameraAvailable, cameraAvailable,
@@ -109,18 +111,18 @@ const ScanPanel = ({
if (phase === 'capture') { if (phase === 'capture') {
if (isNativeScanner) { if (isNativeScanner) {
return { return {
label: 'Scan Document', label: t('photoTask.scanDocument'),
icon: <DocumentScanner />, icon: <DocumentScanner />,
onClick: handleNativeScan, onClick: handleNativeScan,
} }
} }
if (cameraAvailable) { if (cameraAvailable) {
return { label: 'Capture', icon: <CameraAlt />, onClick: capture } return { label: t('photoTask.capture'), icon: <CameraAlt />, onClick: capture }
} }
// No camera on this device — Upload is the only way forward, so it // No camera on this device — Upload is the only way forward, so it
// graduates from the inline secondary to the footer's primary // graduates from the inline secondary to the footer's primary
return { return {
label: 'Upload Photo', label: t('photoTask.uploadPhoto'),
icon: <PhotoCamera />, icon: <PhotoCamera />,
onClick: openFilePicker, onClick: openFilePicker,
} }
@@ -322,7 +324,7 @@ const ScanPanel = ({
{capturedImage && ( {capturedImage && (
<img <img
src={capturedImage} src={capturedImage}
alt='Failed scan' alt={t('photoTask.failedScan')}
style={{ style={{
width: '100%', width: '100%',
display: 'block', display: 'block',