Merge branch 'develop' into i18n/new-namespaces

This commit is contained in:
Mohamad Tarbin
2026-08-13 19:35:17 -04:00
committed by GitHub
41 changed files with 579 additions and 413 deletions

2
package-lock.json generated
View File

@@ -39,7 +39,7 @@
"@jcesarmobile/capacitor-ocr": "^0.3.0",
"@meauxt/react-swipeable-list": "^1.0.0",
"@mui/icons-material": "^5.16.13",
"@mui/joy": "^5.0.0-beta.20",
"@mui/joy": "5.0.0-beta.52",
"@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4",
"@revenuecat/purchases-capacitor": "^12.0.0",

View File

@@ -71,7 +71,7 @@
"@jcesarmobile/capacitor-ocr": "^0.3.0",
"@meauxt/react-swipeable-list": "^1.0.0",
"@mui/icons-material": "^5.16.13",
"@mui/joy": "^5.0.0-beta.20",
"@mui/joy": "5.0.0-beta.52",
"@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4",
"@revenuecat/purchases-capacitor": "^12.0.0",

111
public/locales/en/auth.json Normal file
View File

@@ -0,0 +1,111 @@
{
"validationError": "Validation Error",
"primaryUsernameRequired": "Primary username is required for sub account login",
"subNameRequired": "Sub account name is required for sub account login",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"loginFailed": "Login Failed",
"genericError": "An error occurred, please try again",
"providerLoginFailed": "{{provider}} Login Failed",
"providerLoginFailedMsg": "Couldn't log in with {{provider}}, please try again",
"providerLoginError": "{{provider}} Login Error",
"networkError": "Network error occurred, please try again",
"mfaFailed": "Two-Factor Authentication Failed",
"oauthError": "OAuth Error",
"oauthBrowserFailed": "Failed to open authentication browser",
"primaryAccount": "Primary Account",
"subAccount": "Sub Account",
"username": "Username",
"primaryUsernamePlaceholder": "Enter primary account username",
"subNamePlaceholder": "Enter sub account name",
"forgotPassword": "Forgot password?",
"or": "or",
"email": "Email",
"displayNamePlaceholder": "How others see your name",
"signupFailed": "Signup Failed",
"signupDisabled": "Signup disabled, please contact admin",
"usernameMinLength": "Username must be at least 4 characters",
"invalidEmail": "Invalid email address",
"passwordLength": "Password must be between 8 and 64 characters",
"displayNameRequired": "Display name is required",
"displayNameChars": "Display name can only contain letters and numbers",
"resetEmailSent": "Reset Email Sent",
"resetEmailSentMsg": "Check your email for password reset instructions",
"resetFailed": "Reset Failed",
"resetFailedMsg": "Failed to send reset email, please try again later",
"emailPlaceholder": "you@example.com",
"passwordUpdated": "Password Updated",
"passwordUpdatedMsg": "Your password has been updated successfully. Redirecting to login...",
"passwordUpdateFailed": "Password Update Failed",
"passwordUpdateFailedMsg": "Failed to update password, please try again later",
"savePassword": "Save password",
"server": {
"url": "Server URL",
"dnsFailed": "Hostname could not be resolved. Check the URL for typos or verify DNS.",
"unreachable": "Unable to reach the server. Check the URL, port, and network connection.",
"invalidUrl": "Invalid URL format. Include the protocol (http:// or https://) and port if needed."
},
"mfaModal": {
"title": "Two-factor authentication",
"codeRequired": "Please enter a verification code",
"verifyFailed": "Failed to verify code. Please try again.",
"backupHint": "Enter one of the backup codes you saved when setting up two-factor authentication.",
"codeHint": "Enter the 6-digit code from your authenticator app.",
"verify": "Verify & Sign In",
"backupLabel": "Backup code",
"codeLabel": "Verification code",
"backupPlaceholder": "Enter backup code",
"useAuthenticator": "Use authenticator app instead",
"useBackup": "Use a backup code instead",
"backupOnce": "Each backup code can only be used once.",
"invalidCode": "Invalid verification code. Please try again."
},
"appleLoginFailed": "Apple Login Failed",
"googleLoginFailed": "Google Login Failed",
"createOne": "Create one",
"primaryAccountUsernameLabel": "Primary account username",
"subAccountNameLabel": "Sub account name",
"serverSettings": "Server settings",
"useDifferentAccount": "Use a different account",
"yourUsername": "Your username",
"backToSignIn": "Back to sign in",
"confirmNewPassword": "Confirm new password",
"newPassword": "New password",
"reenterPassword": "Re-enter your password",
"requestNewLink": "Request a new link",
"setNewPassword": "Set a new password",
"linkInvalid": "This link is not valid",
"createAccountButton": "Create account",
"createYourAccount": "Create your account",
"displayNameLabel": "Display name",
"usernameHint": "lowercase letters, numbers, dot and dash",
"termsShort": "By creating an account you agree to our Terms of Service and Privacy",
"checkYourEmail": "Check your email",
"resetYourPassword": "Reset your password",
"serverChangeWarning": "Changing the server clears locally cached data on this device.",
"serverConnected": "Connected. Taking you to sign in...",
"passwordLabel": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"loginPasswordPlaceholder": "Enter your password",
"signupPasswordPlaceholder": "At least 8 characters",
"signupPasswordHelper": "Use 8 to 64 characters.",
"orContinueWith": "or continue with",
"signupSignInFailed": "Your account was created, but signing in failed. Please sign in.",
"authenticating": {
"signingIn": "Signing you in",
"signingInSub": "This will only take a moment.",
"unknownProvider": "Unknown sign-in provider",
"contactSupport": "Please contact support.",
"signInFailed": "Sign-in failed",
"mfaCancelled": "Two-factor authentication was cancelled.",
"requestNotVerified": "The sign-in request could not be verified.",
"tryAgain": "Please try again.",
"mfaSessionMissing": "The MFA session is missing. Please try again.",
"twoFactor": "Two-factor authentication",
"verifyToContinue": "Verify your login to continue.",
"noToken": "No valid authentication token was returned.",
"backToSignIn": "Back to sign in",
"mfaFailed": "Two-factor authentication failed. Please try again."
}
}

View File

@@ -29,7 +29,8 @@
"filters": "Filters",
"activities": "Activities",
"points": "Points",
"settings": "Settings"
"settings": "Settings",
"reportBug": "Report a Bug"
},
"search": {
"title": "Search",
@@ -114,5 +115,37 @@
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
},
"done": "Done",
"clearAll": "Clear all",
"upload": {
"quotaTitle": "Storage Quota Exceeded",
"quotaMessage": "You have exceeded your quota for uploading files.",
"tooLargeTitle": "File Too Large",
"tooLargeMessage": "The file you are trying to upload is too large.",
"deniedTitle": "Permission Denied",
"deniedMessage": "You do not have permission to upload files."
},
"getStarted": "Get Started!",
"imageLoadFailed": "Failed to load image.",
"typeHere": "Type in here…",
"summaryOfChores": "This is a summary of your chores",
"loadingOffline": "You are offline",
"loadingOfflineSub": "This not available while offline. Please check your internet connection and try again.",
"loadingSlow": "This is taking longer than usual. There might be an issue.",
"navigateBack": "Navigate Back",
"bottomNav": "Bottom Navigation",
"profile": "Profile",
"autocompletePlaceholder": "Type here...",
"errorScreen": {
"title": "Something went wrong",
"fallback": "An unexpected error occurred. Try reloading — it usually fixes it.",
"tryAgain": "Try again",
"home": "Home",
"login": "Login",
"hideDetails": "Hide error details",
"showDetails": "Show error details",
"copyToClipboard": "Copy to clipboard",
"copied": "Error details copied to clipboard"
}
}

View File

@@ -63,11 +63,14 @@ const LogoContainer = styled(Box)({
marginBottom: '24px',
})
import { useTranslation } from 'react-i18next'
const LoadingScreen = ({
message = 'Loading...',
message = null,
showLogo = true,
size = 'lg',
}) => {
const { t } = useTranslation('common')
return (
<LoadingContainer>
<LoadingContent>
@@ -82,7 +85,7 @@ const LoadingScreen = ({
mb: 1,
}}
>
Done
{t('done')}
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
</LogoContainer>
@@ -96,7 +99,7 @@ const LoadingScreen = ({
}}
/>
<PulsingText level='body-md'>{message}</PulsingText>
<PulsingText level='body-md'>{message ?? t('loading')}</PulsingText>
</LoadingContent>
</LoadingContainer>
)

View File

@@ -2,7 +2,6 @@ import { Close } from '@mui/icons-material'
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
import useMediaQuery from '@mui/material/useMediaQuery'
import { forwardRef, useId } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const WIDTH_BY_SIZE = {
sm: 400,
@@ -27,6 +26,7 @@ const AppModal = forwardRef(
size = 'md',
fullWidth = true,
isMobile: isMobileProp,
keepMounted = false,
mobilePresentation = 'sheet',
role = 'dialog',
showCloseButton = true,
@@ -63,9 +63,16 @@ const AppModal = forwardRef(
onClose={handleClose}
aria-labelledby={titleId}
aria-describedby={descriptionId}
keepMounted
keepMounted={keepMounted}
sx={{
zIndex: Z_INDEX.MODAL_BACKDROP,
// Joy raises portaled listboxes above modals, but its selector misses
// Menu because Menu renders with role="menu". Keep the workaround in
// the modal primitive so individual menus never need a z-index.
...(open && {
'& ~ [role="menu"]': {
'--unstable_popup-zIndex': 'calc(var(--joy-zIndex-modal) + 1)',
},
}),
display: 'flex',
alignItems: isSheet ? 'flex-end' : 'center',
justifyContent: 'center',
@@ -85,7 +92,6 @@ const AppModal = forwardRef(
aria-describedby={descriptionId}
variant='outlined'
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
display: 'flex',
flexDirection: 'column',
width: isFullscreen

View File

@@ -1,6 +1,8 @@
import { Add, Close } from '@mui/icons-material'
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
const ActiveFilterChips = ({
chipSize = 'md',
chipSx,
@@ -18,6 +20,7 @@ const ActiveFilterChips = ({
showAddChip = false,
totalCount,
}) => {
const { t } = useTranslation('common')
if (!chips.length) {
return null
}
@@ -161,7 +164,7 @@ const ActiveFilterChips = ({
...clearButtonSx,
}}
>
Clear all
{t('clearAll')}
</Button>
)}
</Box>

View File

@@ -0,0 +1,34 @@
import {
AccountCircle,
Api,
Circle,
Code,
FamilyRestroom,
Language,
Notifications,
Palette,
Person,
Security,
Settings,
Storage,
ViewSidebar,
} from '@mui/icons-material'
// Single source of truth for the settings sections: id, icon, and access
// gating. Titles/descriptions live in locales/settings.json under
// `overview.sections.<id>`, keyed off the same ids.
export const SETTINGS_SECTIONS = [
{ id: 'profile', icon: Person },
{ id: 'circle', icon: Circle, parentOnly: true },
{ id: 'account', icon: AccountCircle, parentOnly: true },
{ id: 'subaccounts', icon: FamilyRestroom },
{ id: 'notifications', icon: Notifications },
{ id: 'mfa', icon: Security, parentOnly: true },
{ id: 'apitokens', icon: Api, parentOnly: true },
{ id: 'storage', icon: Storage },
{ id: 'sidepanel', icon: ViewSidebar },
{ id: 'theme', icon: Palette },
{ id: 'localization', icon: Language, isBeta: true },
{ id: 'advanced', icon: Settings },
{ id: 'developer', icon: Code },
]

View File

@@ -1,38 +0,0 @@
// Z-index constants for consistent layering
// Lower values appear behind higher values
export const Z_INDEX = {
// Base layer (0-99)
BASE: 0,
CARD_OVERLAY: 1,
DROPDOWN_ITEM: 2,
TOOLTIP: 3,
// UI Components (100-999)
SAFE_AREA: 100,
CALENDAR: 110,
SMART_INPUT: 110,
AUTOCOMPLETE: 200,
// Navigation (1000-1999)
NAVBAR: 1000,
DRAWER: 999,
// Modals and Overlays (2000-8999)
MODAL_BACKDROP: 2000,
MODAL_CONTENT: 2001,
MODAL_CLOSE_BUTTON: 2002,
// Popups that must float above open modals (portaled to document.body)
MODAL_POPOVER: 2100,
TOAST: 3000,
// Critical System UI (9000-9999)
LOADING_SCREEN: 9000,
ALERTS: 9500,
NETWORK_BANNER: 9600,
// Maximum (10000+) - Reserved for absolute emergencies
EMERGENCY: 10000,
}
export default Z_INDEX

View File

@@ -1,14 +1,16 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
const useConfirmationModal = () => {
const { t } = useTranslation('common')
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText = t('confirm'),
cancelText = t('cancel'),
color = 'primary',
) => {
setConfirmModalConfig({

View File

@@ -5,12 +5,14 @@ import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
import { useTranslation } from 'react-i18next'
export const useFileUpload = ({
draftId,
entityId,
entityType = 'chore_attachment',
} = {}) => {
const { t } = useTranslation('common')
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
@@ -58,14 +60,14 @@ export const useFileUpload = ({
if (response.status === 507) {
showError({
title: 'Storage Quota Exceeded',
message: 'You have exceeded your quota for uploading files.',
title: t('upload.quotaTitle'),
message: t('upload.quotaMessage'),
})
return null
} else if (response.status === 413) {
showError({
title: 'File Too Large',
message: 'The file you are trying to upload is too large.',
title: t('upload.tooLargeTitle'),
message: t('upload.tooLargeMessage'),
})
return null
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
@@ -76,8 +78,8 @@ export const useFileUpload = ({
return null
} else if (response.status === 403) {
showError({
title: 'Permission Denied',
message: 'You do not have permission to upload files.',
title: t('upload.deniedTitle'),
message: t('upload.deniedMessage'),
})
return null
} else if (!response.ok) {
@@ -105,7 +107,7 @@ export const useFileUpload = ({
return null
}
},
[entityType, entityId, draftId, showError, userProfile],
[entityType, entityId, draftId, showError, userProfile, t],
)
return { uploadFile, isPlus: isPlusAccount(userProfile) }

View File

@@ -23,6 +23,7 @@ i18n
},
ns: [
'auth'
'common',
'settings',
'chores',

View File

@@ -8,6 +8,7 @@ import {
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import { offlineDB } from '../utils/OfflineDB'
@@ -34,6 +35,7 @@ const uniqueBy = (items, getId) => [
export const GlobalSearchProvider = ({ children }) => {
const queryClient = useQueryClient()
const { t } = useTranslation('settings')
const location = useLocation()
const navigate = useNavigate()
const isMobile = useMediaQuery('(max-width:768px)')
@@ -110,6 +112,7 @@ export const GlobalSearchProvider = ({ children }) => {
labels,
members,
isParent: isParentUser(profile),
t,
choresById: new Map(chores.map(item => [String(item.id), item])),
projectsById: new Map(projects.map(item => [String(item.id), item])),
membersById: new Map(members.map(item => [String(item.userId), item])),
@@ -127,7 +130,7 @@ export const GlobalSearchProvider = ({ children }) => {
} finally {
setIsLoading(false)
}
}, [queryClient])
}, [queryClient, t])
const openSearch = useCallback(
(query = '') => {

View File

@@ -1,3 +1,5 @@
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
@@ -17,26 +19,6 @@ const HISTORY_STATUS = {
6: 'rescheduled',
}
const SETTINGS = [
['profile', 'Profile', 'Name, avatar and personal details'],
['circle', 'Circle', 'Members and household settings', true],
['account', 'Account', 'Subscription and account management', true],
['subaccounts', 'Subaccounts', 'Manage child accounts'],
['notifications', 'Notifications', 'Reminders and notification preferences'],
['mfa', 'Multi-factor authentication', 'Secure your account', true],
['apitokens', 'API tokens', 'Manage integrations and access tokens', true],
['storage', 'Storage', 'Files, backups and device storage'],
['sidepanel', 'Side panel', 'Customize navigation'],
['theme', 'Appearance', 'Theme, dark mode and colors'],
['localization', 'Language and region', 'Language, dates and time formats'],
[
'advanced',
'Advanced settings',
'Offline support, webhooks and application behavior',
],
['developer', 'Developer settings', 'Diagnostics and experimental tools'],
]
const providers = []
export const registerSearchProvider = provider => {
@@ -166,15 +148,15 @@ registerSearchProvider({
registerSearchProvider({
id: 'settings',
getDocuments: ({ isParent }) =>
SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map(
([id, title, description]) =>
getDocuments: ({ isParent, t }) =>
SETTINGS_SECTIONS.filter(({ parentOnly }) => !parentOnly || isParent).map(
({ id }) =>
document('settings', {
id: `setting:${id}`,
entityId: id,
title,
title: t(`overview.sections.${id}.title`),
subtitle: 'Settings',
body: description,
body: t(`overview.sections.${id}.description`),
keywords: `preferences configuration ${id}`,
route: `/settings/${id}`,
}),

View File

@@ -1,7 +1,6 @@
import { Alert, Box } from '@mui/joy'
import PropTypes from 'prop-types'
import { createContext, useCallback, useContext, useState } from 'react'
import Z_INDEX from '../constants/zIndex'
const FADE_DURATION = 400 // ms
const ALERT_DURATION = 5000 // ms
@@ -48,7 +47,7 @@ export const AlertsProvider = ({ children }) => {
top: 0,
left: 0,
width: '100%',
zIndex: Z_INDEX.ALERTS,
zIndex: 'var(--joy-zIndex-snackbar)',
overflow: 'hidden',
}}
>

View File

@@ -12,6 +12,7 @@ import {
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { authButtonSx, authInputSx } from './authStyles'
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
@@ -40,12 +41,13 @@ export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
)
export const AuthPasswordField = ({
label = 'Password',
label,
error,
helper,
sx,
...inputProps
}) => {
const { t } = useTranslation('auth')
const [visible, setVisible] = useState(false)
return (
@@ -60,7 +62,7 @@ export const AuthPasswordField = ({
color='neutral'
size='sm'
tabIndex={-1}
aria-label={visible ? 'Hide password' : 'Show password'}
aria-label={visible ? t('hidePassword') : t('showPassword')}
onClick={() => setVisible(v => !v)}
sx={{ borderRadius: '8px' }}
>
@@ -110,27 +112,31 @@ export const SocialButton = ({ icon, children, sx, ...props }) => (
</Button>
)
export const AuthDivider = ({ children = 'or' }) => (
<Box
role='separator'
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
my: 2.5,
'&::before, &::after': {
content: '""',
flex: 1,
height: '1px',
bgcolor: 'divider',
},
}}
>
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
{children}
</Typography>
</Box>
)
export const AuthDivider = ({ children }) => {
const { t } = useTranslation('auth')
return (
<Box
role='separator'
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
my: 2.5,
'&::before, &::after': {
content: '""',
flex: 1,
height: '1px',
bgcolor: 'divider',
},
}}
>
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
{children ?? t('or')}
</Typography>
</Box>
)
}
export const LegalLinks = () => (
<Typography

View File

@@ -10,16 +10,18 @@ import { apiClient } from '../../utils/ApiClient'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import { useTranslation } from 'react-i18next'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import MFAVerificationModal from './MFAVerificationModal'
const AuthenticationLoading = () => {
const { t } = useTranslation('auth')
const { refetch: refetchUserProfile } = useUserProfile()
const Navigate = useNavigate()
const hasCalledHandleOAuth2 = useRef(false)
const [message, setMessage] = useState('Signing you in')
const [subMessage, setSubMessage] = useState('This will only take a moment.')
const [message, setMessage] = useState(t('authenticating.signingIn'))
const [subMessage, setSubMessage] = useState(t('authenticating.signingInSub'))
const [status, setStatus] = useState('pending')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
@@ -31,8 +33,8 @@ const AuthenticationLoading = () => {
// suppress a genuine session expiry later on.
handleOAuth2().finally(endOAuthExchange)
} else if (provider !== 'oauth2') {
setMessage('Unknown sign-in provider')
setSubMessage('Please contact support.')
setMessage(t('authenticating.unknownProvider'))
setSubMessage(t('authenticating.contactSupport'))
setStatus('error')
}
return endOAuthExchange
@@ -71,8 +73,8 @@ const AuthenticationLoading = () => {
const handleMFAClose = () => {
setMfaModalOpen(false)
setMfaSessionToken('')
setMessage('Sign-in failed')
setSubMessage('Two-factor authentication was cancelled.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.mfaCancelled'))
setStatus('error')
}
@@ -85,8 +87,8 @@ const AuthenticationLoading = () => {
const storedState = localStorage.getItem('authState')
if (returnedState !== storedState) {
setMessage('Sign-in failed')
setSubMessage('The sign-in request could not be verified.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.requestNotVerified'))
setStatus('error')
return
}
@@ -112,8 +114,8 @@ const AuthenticationLoading = () => {
if (!response.ok) {
console.error('Authentication failed')
setMessage('Sign-in failed')
setSubMessage('Please try again.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.tryAgain'))
setStatus('error')
return
}
@@ -122,22 +124,22 @@ const AuthenticationLoading = () => {
if (data.mfaRequired) {
if (!data.sessionToken) {
setMessage('Sign-in failed')
setSubMessage('The MFA session is missing. Please try again.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.mfaSessionMissing'))
setStatus('error')
return
}
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
setMessage('Two-factor authentication')
setSubMessage('Verify your login to continue.')
setMessage(t('authenticating.twoFactor'))
setSubMessage(t('authenticating.verifyToContinue'))
return
}
if (!data.token && !data.access_token) {
setMessage('Sign-in failed')
setSubMessage('No valid authentication token was returned.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.noToken'))
setStatus('error')
return
}
@@ -158,8 +160,8 @@ const AuthenticationLoading = () => {
}
} catch (error) {
console.error('Authentication request failed', error)
setMessage('Sign-in failed')
setSubMessage('Please try again.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.tryAgain'))
setStatus('error')
}
}
@@ -188,7 +190,7 @@ const AuthenticationLoading = () => {
fullWidth
sx={authButtonSx}
>
Back to sign in
{t('authenticating.backToSignIn')}
</Button>
)}
</Box>
@@ -199,8 +201,8 @@ const AuthenticationLoading = () => {
sessionToken={mfaSessionToken}
onSuccess={handleMFASuccess}
onError={() => {
setMessage('Sign-in failed')
setSubMessage('Two-factor authentication failed. Please try again.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.mfaFailed'))
}}
/>
</AuthShell>

View File

@@ -1,6 +1,7 @@
import MarkEmailReadOutlined from '@mui/icons-material/MarkEmailReadOutlined'
import { Box, Button, Link, Typography } from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useNotification } from '../../service/NotificationProvider'
import { ResetPassword } from '../../utils/Fetcher'
@@ -12,6 +13,7 @@ const isInvalidEmail = email =>
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
const ForgotPasswordView = () => {
const { t } = useTranslation('auth')
const navigate = useNavigate()
const [resetStatusOk, setResetStatusOk] = useState(null)
const [email, setEmail] = useState('')
@@ -40,21 +42,21 @@ const ForgotPasswordView = () => {
setResetStatusOk(true)
showNotification({
type: 'success',
title: 'Reset Email Sent',
message: 'Check your email for password reset instructions',
title: t('resetEmailSent'),
message: t('resetEmailSentMsg'),
})
} else {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
title: t('resetFailed'),
message: t('resetFailedMsg'),
})
}
} catch (error) {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
title: t('resetFailed'),
message: t('resetFailedMsg'),
})
} finally {
setIsSubmitting(false)
@@ -79,7 +81,7 @@ const ForgotPasswordView = () => {
if (resetStatusOk !== null) {
return (
<AuthShell
title='Check your email'
title={t('checkYourEmail')}
subtitle={`If an account exists for ${email}, we've sent instructions for resetting your password.`}
footer={<LegalLinks />}
logoSize={0}
@@ -101,7 +103,7 @@ const ForgotPasswordView = () => {
sx={authButtonSx}
onClick={() => navigate('/login')}
>
Back to sign in
{t('backToSignIn')}
</Button>
</Box>
</AuthShell>
@@ -110,7 +112,7 @@ const ForgotPasswordView = () => {
return (
<AuthShell
title='Reset your password'
title={t('resetYourPassword')}
subtitle="Enter your email and we'll send you a link to get back into your account."
footer={<LegalLinks />}
logoSize={0}
@@ -126,7 +128,7 @@ const ForgotPasswordView = () => {
name='email'
type='email'
autoComplete='email'
placeholder='you@example.com'
placeholder={t('emailPlaceholder')}
autoFocus
value={email}
error={emailError}
@@ -152,7 +154,7 @@ const ForgotPasswordView = () => {
underline='hover'
onClick={() => navigate('/login')}
>
Back to sign in
{t('backToSignIn')}
</Link>
</Typography>
</AuthShell>

View File

@@ -12,10 +12,12 @@ import { offlineDB } from '../../utils/OfflineDB'
import { AuthSubmitButton, AuthTextField } from './AuthFields'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import { useTranslation } from 'react-i18next'
const CONNECTION_TIMEOUT_MS = 8000
const LoginSettings = () => {
const { t } = useTranslation('auth')
const Navigate = useNavigate()
const { refetch: refetchResource } = useResource()
const [serverURL, setServerURL] = React.useState('')
@@ -105,7 +107,7 @@ const LoginSettings = () => {
return {
ok: false,
message:
'Hostname could not be resolved. Check the URL for typos or verify DNS.',
t('server.dnsFailed'),
}
}
return {
@@ -118,7 +120,7 @@ const LoginSettings = () => {
return {
ok: false,
message:
'Unable to reach the server. Check the URL, port, and network connection.',
t('server.unreachable'),
}
}
@@ -126,7 +128,7 @@ const LoginSettings = () => {
return {
ok: false,
message:
'Unable to reach the server. Check the URL, port, and network connection.',
t('server.unreachable'),
}
}
}
@@ -144,7 +146,7 @@ const LoginSettings = () => {
if (!isValidURL(trimmedURL)) {
setStatus('error')
setErrorMessage(
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
t('server.invalidUrl'),
)
return
}
@@ -187,7 +189,7 @@ const LoginSettings = () => {
return (
<AuthShell
title='Server settings'
title={t('serverSettings')}
subtitle='Point the app at your own self-hosted Donetick server.'
>
<Box
@@ -196,7 +198,7 @@ const LoginSettings = () => {
sx={{ display: 'flex', flexDirection: 'column' }}
>
<AuthTextField
label='Server URL'
label={t('server.url')}
id='serverURL'
name='serverURL'
inputMode='url'
@@ -243,7 +245,7 @@ const LoginSettings = () => {
startDecorator={<CheckCircleOutlineIcon />}
sx={{ mt: 2, borderRadius: '12px' }}
>
Connected. Taking you to sign in...
{t('serverConnected')}
</Alert>
)}
@@ -290,7 +292,7 @@ const LoginSettings = () => {
level='body-xs'
sx={{ mt: 2.5, textAlign: 'center', color: 'text.secondary' }}
>
Changing the server clears locally cached data on this device.
{t('serverChangeWarning')}
</Typography>
</AuthShell>
)

View File

@@ -10,6 +10,7 @@ import { Avatar, Box, Button, IconButton, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { LoginSocialGoogle } from 'reactjs-social-login'
@@ -84,6 +85,7 @@ const SegmentedControl = ({ value, onChange, options }) => (
)
const LoginView = () => {
const { t } = useTranslation('auth')
// Use React Query client directly to invalidate the user profile query
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
@@ -158,23 +160,23 @@ const LoginView = () => {
if (loginType === 'sub') {
if (!parentUsername.trim()) {
showError({
title: 'Validation Error',
message: 'Primary username is required for sub account login',
title: t('validationError'),
message: t('primaryUsernameRequired'),
})
return
}
if (!childName.trim()) {
showError({
title: 'Validation Error',
message: 'Sub account name is required for sub account login',
title: t('validationError'),
message: t('subNameRequired'),
})
return
}
} else {
if (!username.trim()) {
showError({
title: 'Validation Error',
message: 'Username is required',
title: t('validationError'),
message: t('usernameRequired'),
})
return
}
@@ -182,8 +184,8 @@ const LoginView = () => {
if (!password) {
showError({
title: 'Validation Error',
message: 'Password is required',
title: t('validationError'),
message: t('passwordRequired'),
})
return
}
@@ -200,7 +202,7 @@ const LoginView = () => {
result = await authLogin({ username: actualUsername, password })
} catch (error) {
showError({
title: 'Login Failed',
title: t('loginFailed'),
message: error?.message || 'An error occurred, please try again',
})
return
@@ -227,8 +229,8 @@ const LoginView = () => {
}
} else {
showError({
title: 'Login Failed',
message: result.error || 'An error occurred, please try again',
title: t('loginFailed'),
message: result.error || t('genericError'),
})
}
}
@@ -298,15 +300,15 @@ const LoginView = () => {
} else {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: `${providerName} Login Failed`,
message: `Couldn't log in with ${providerName}, please try again`,
title: t('providerLoginFailed', { provider: providerName }),
message: t('providerLoginFailedMsg', { provider: providerName }),
})
}
} catch (error) {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: `${providerName} Login Error`,
message: 'Network error occurred, please try again',
title: t('providerLoginError', { provider: providerName }),
message: t('networkError'),
})
}
}
@@ -350,7 +352,7 @@ const LoginView = () => {
const handleMFAError = errorMessage => {
showError({
title: 'Two-Factor Authentication Failed',
title: t('mfaFailed'),
message: errorMessage,
})
}
@@ -398,8 +400,8 @@ const LoginView = () => {
} catch (error) {
console.error('Failed to open OAuth browser:', error)
showError({
title: 'OAuth Error',
message: 'Failed to open authentication browser',
title: t('oauthError'),
message: t('oauthBrowserFailed'),
})
}
} else {
@@ -439,7 +441,7 @@ const LoginView = () => {
<IconButton
variant='plain'
color='neutral'
aria-label='Server settings'
aria-label={t('serverSettings')}
onClick={() => Navigate('/login/settings')}
>
<SettingsOutlined />
@@ -465,7 +467,7 @@ const LoginView = () => {
<Typography level='title-md'>{displayName}</Typography>
{getUserDisplayInfo(userProfile).userType === 'child' && (
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
Sub Account
{t('subAccount')}
</Typography>
)}
</Box>
@@ -486,7 +488,7 @@ const LoginView = () => {
sx={authButtonSx}
onClick={() => apiClient.handleLogout()}
>
Use a different account
{t('useDifferentAccount')}
</Button>
</Box>
) : (
@@ -499,19 +501,19 @@ const LoginView = () => {
value={loginType}
onChange={handleLoginModeChange}
options={[
{ value: 'primary', label: 'Primary Account' },
{ value: 'sub', label: 'Sub Account' },
{ value: 'primary', label: t('primaryAccount') },
{ value: 'sub', label: t('subAccount') },
]}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{loginType === 'primary' ? (
<AuthTextField
label='Username'
label={t('username')}
id='username'
name='username'
autoComplete='username'
placeholder='Your username'
placeholder={t('yourUsername')}
autoFocus
value={username}
onChange={e => setUsername(e.target.value)}
@@ -519,20 +521,20 @@ const LoginView = () => {
) : (
<>
<AuthTextField
label='Primary account username'
label={t('primaryAccountUsernameLabel')}
id='parentUsername'
name='parentUsername'
autoComplete='username'
placeholder='Enter primary account username'
placeholder={t('primaryUsernamePlaceholder')}
autoFocus
value={parentUsername}
onChange={e => setParentUsername(e.target.value)}
/>
<AuthTextField
label='Sub account name'
label={t('subAccountNameLabel')}
id='childName'
name='childName'
placeholder='Enter sub account name'
placeholder={t('subNamePlaceholder')}
value={childName}
onChange={e => setChildName(e.target.value)}
/>
@@ -544,7 +546,8 @@ const LoginView = () => {
id='password'
name='password'
autoComplete='current-password'
placeholder='Enter your password'
label={t('passwordLabel')}
placeholder={t('loginPasswordPlaceholder')}
value={password}
onChange={e => setPassword(e.target.value)}
/>
@@ -556,7 +559,7 @@ const LoginView = () => {
underline='hover'
onClick={handleForgotPassword}
>
Forgot password?
{t('forgotPassword')}
</Link>
</Box>
</Box>
@@ -568,7 +571,7 @@ const LoginView = () => {
</Box>
)}
{hasSocialOptions && <AuthDivider>or continue with</AuthDivider>}
{hasSocialOptions && <AuthDivider>{t('orContinueWith')}</AuthDivider>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{showSocialLogin && !Capacitor.isNativePlatform() && (
@@ -584,7 +587,7 @@ const LoginView = () => {
}}
onReject={() => {
showError({
title: 'Google Login Failed',
title: t('googleLoginFailed'),
message: "Couldn't log in with Google, please try again",
})
}}
@@ -608,7 +611,7 @@ const LoginView = () => {
} catch (error) {
console.error('Google login error:', error)
showError({
title: 'Google Login Failed',
title: t('googleLoginFailed'),
message: `Couldn't log in with Google, please try again${
error?.message ? `: ${error.message}` : ''
}`,
@@ -637,7 +640,7 @@ const LoginView = () => {
.catch(error => {
console.error('Apple login error:', error)
showError({
title: 'Apple Login Failed',
title: t('appleLoginFailed'),
message: "Couldn't log in with Apple, please try again",
})
})
@@ -670,7 +673,7 @@ const LoginView = () => {
underline='hover'
onClick={() => Navigate('/signup')}
>
Create one
{t('createOne')}
</Link>
</Typography>
)}

View File

@@ -5,6 +5,7 @@ import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { VerifyMFA } from '../../utils/Fetcher'
import { authInputSx } from './authStyles'
import { useTranslation } from 'react-i18next'
const MFAVerificationModal = ({
open,
@@ -13,6 +14,7 @@ const MFAVerificationModal = ({
onSuccess,
onError,
}) => {
const { t } = useTranslation('auth')
const [verificationCode, setVerificationCode] = useState('')
const [isBackupCode, setIsBackupCode] = useState(false)
const [loading, setLoading] = useState(false)
@@ -21,7 +23,7 @@ const MFAVerificationModal = ({
const handleVerify = async () => {
if (!verificationCode.trim()) {
setError('Please enter a verification code')
setError(t('mfaModal.codeRequired'))
return
}
@@ -37,14 +39,14 @@ const MFAVerificationModal = ({
} else {
const errorData = await response.json()
const message =
errorData.message || 'Invalid verification code. Please try again.'
errorData.message || t('mfaModal.invalidCode')
setError(message)
onError?.(message)
}
} catch (error) {
// A wrong code is shown inline; a failed request is escalated to the
// caller so it can surface a toast instead of looking like a bad code.
const message = 'Failed to verify code. Please try again.'
const message = t('mfaModal.verifyFailed')
setError(message)
onError?.(message)
console.error('MFA verification error:', error)
@@ -73,23 +75,23 @@ const MFAVerificationModal = ({
open={open}
onClose={handleClose}
size='md'
title='Two-factor authentication'
title={t('mfaModal.title')}
description={
isBackupCode
? 'Enter one of the backup codes you saved when setting up two-factor authentication.'
: 'Enter the 6-digit code from your authenticator app.'
? t('mfaModal.backupHint')
: t('mfaModal.codeHint')
}
closeOnBackdrop={!loading}
closeOnEscape={!loading}
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('common:cancel'),
onClick: handleClose,
disabled: loading,
}}
primary={{
label: 'Verify & Sign In',
label: t('mfaModal.verify'),
onClick: handleVerify,
loading,
disabled: !verificationCode.trim(),
@@ -105,12 +107,12 @@ const MFAVerificationModal = ({
level='body-sm'
sx={{ display: 'block', fontWeight: 600, mb: 0.75 }}
>
{isBackupCode ? 'Backup code' : 'Verification code'}
{isBackupCode ? t('mfaModal.backupLabel') : t('mfaModal.codeLabel')}
</Typography>
<Input
id='mfa-code'
size='lg'
placeholder={isBackupCode ? 'Enter backup code' : '000000'}
placeholder={isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyDown={handleKeyDown}
@@ -157,8 +159,8 @@ const MFAVerificationModal = ({
}}
>
{isBackupCode
? 'Use authenticator app instead'
: 'Use a backup code instead'}
? t('mfaModal.useAuthenticator')
: t('mfaModal.useBackup')}
</Link>
</Box>
@@ -167,7 +169,7 @@ const MFAVerificationModal = ({
level='body-xs'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
Each backup code can only be used once.
{t('mfaModal.backupOnce')}
</Typography>
)}
</Stack>

View File

@@ -1,6 +1,7 @@
import { Box, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth.jsx'
@@ -16,6 +17,7 @@ import {
import AuthShell from './AuthShell'
const SignupView = () => {
const { t } = useTranslation('auth')
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const Navigate = useNavigate()
@@ -38,7 +40,7 @@ const SignupView = () => {
showError({
title: 'Almost there',
message:
'Your account was created, but signing in failed. Please sign in.',
t('signupSignInFailed'),
})
Navigate('/login')
return
@@ -72,36 +74,36 @@ const SignupView = () => {
let isValid = true
if (!username.trim()) {
setUsernameError('Username is required')
setUsernameError(t('usernameRequired'))
isValid = false
}
if (username.length < 4) {
setUsernameError('Username must be at least 4 characters')
setUsernameError(t('usernameMinLength'))
isValid = false
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setEmailError('Invalid email address')
setEmailError(t('invalidEmail'))
isValid = false
}
if (password.length < 8) {
setPasswordError('Password must be between 8 and 64 characters')
setPasswordError(t('passwordLength'))
isValid = false
}
if (password.length > 64) {
setPasswordError('Password must be between 8 and 64 characters')
setPasswordError(t('passwordLength'))
isValid = false
}
if (!displayName.trim()) {
setDisplayNameError('Display name is required')
setDisplayNameError(t('displayNameRequired'))
isValid = false
}
// display name should only contain letters and spaces and numbers:
if (!/^[a-zA-Z0-9 ]+$/.test(displayName)) {
setDisplayNameError('Display name can only contain letters and numbers')
setDisplayNameError(t('displayNameChars'))
isValid = false
}
@@ -127,14 +129,14 @@ const SignupView = () => {
handleLogin(username, password)
} else if (response.status === 403) {
showError({
title: 'Signup Failed',
message: 'Signup disabled, please contact admin',
title: t('signupFailed'),
message: t('signupDisabled'),
})
} else {
console.log('Signup failed')
response.json().then(res => {
showError({
title: 'Signup Failed',
title: t('signupFailed'),
message: res.error || 'An error occurred during signup',
})
})
@@ -145,7 +147,7 @@ const SignupView = () => {
return (
<AuthShell
title='Create your account'
title={t('createYourAccount')}
subtitle={
getPendingInvite()
? 'Create an account and well send your circle join request right after.'
@@ -160,11 +162,11 @@ const SignupView = () => {
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<AuthTextField
label='Display name'
label={t('displayNameLabel')}
id='displayName'
name='displayName'
autoComplete='name'
placeholder='How others see your name'
placeholder={t('displayNamePlaceholder')}
autoFocus
value={displayName}
error={displayNameError}
@@ -175,11 +177,11 @@ const SignupView = () => {
/>
<AuthTextField
label='Username'
label={t('username')}
id='username'
name='username'
autoComplete='username'
placeholder='lowercase letters, numbers, dot and dash'
placeholder={t('usernameHint')}
value={username}
error={usernameError}
onChange={e => {
@@ -189,12 +191,12 @@ const SignupView = () => {
/>
<AuthTextField
label='Email'
label={t('email')}
id='email'
name='email'
type='email'
autoComplete='email'
placeholder='you@example.com'
placeholder={t('emailPlaceholder')}
value={email}
error={emailError}
onChange={e => {
@@ -207,10 +209,11 @@ const SignupView = () => {
id='password'
name='password'
autoComplete='new-password'
placeholder='At least 8 characters'
label={t('passwordLabel')}
placeholder={t('signupPasswordPlaceholder')}
value={password}
error={passwordError}
helper='Use 8 to 64 characters.'
helper={t('signupPasswordHelper')}
onChange={e => {
setPasswordError(null)
setPassword(e.target.value)
@@ -218,14 +221,14 @@ const SignupView = () => {
/>
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
Create account
{t('createAccountButton')}
</AuthSubmitButton>
<Typography
level='body-xs'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
By creating an account you agree to our Terms of Service and Privacy
{t('termsShort')}
Policy.
</Typography>
</Box>

View File

@@ -1,5 +1,6 @@
import { Box, Button } from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useNotification } from '../../service/NotificationProvider'
@@ -9,6 +10,7 @@ import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
const UpdatePasswordView = () => {
const { t } = useTranslation('auth')
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
@@ -67,23 +69,22 @@ const UpdatePasswordView = () => {
if (response.ok) {
showNotification({
type: 'success',
title: 'Password Updated',
message:
'Your password has been updated successfully. Redirecting to login...',
title: t('passwordUpdated'),
message: t('passwordUpdatedMsg'),
})
setTimeout(() => {
navigate('/login')
}, 3000)
} else {
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
title: t('passwordUpdateFailed'),
message: t('passwordUpdateFailedMsg'),
})
}
} catch (error) {
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
title: t('passwordUpdateFailed'),
message: t('passwordUpdateFailedMsg'),
})
} finally {
setIsSubmitting(false)
@@ -93,7 +94,7 @@ const UpdatePasswordView = () => {
if (!verificationCode) {
return (
<AuthShell
title='This link is not valid'
title={t('linkInvalid')}
subtitle='The password reset link is incomplete or has already been used. Request a new one to continue.'
footer={<LegalLinks />}
showLogo
@@ -106,7 +107,7 @@ const UpdatePasswordView = () => {
sx={authButtonSx}
onClick={() => navigate('/forgot-password')}
>
Request a new link
{t('requestNewLink')}
</Button>
<Button
fullWidth
@@ -116,7 +117,7 @@ const UpdatePasswordView = () => {
sx={authButtonSx}
onClick={() => navigate('/login')}
>
Back to sign in
{t('backToSignIn')}
</Button>
</Box>
</AuthShell>
@@ -125,7 +126,7 @@ const UpdatePasswordView = () => {
return (
<AuthShell
title='Set a new password'
title={t('setNewPassword')}
subtitle='Choose a password you have not used on this account before.'
footer={<LegalLinks />}
// Reached from an emailed link, usually in a browser: an unbranded page
@@ -138,7 +139,7 @@ const UpdatePasswordView = () => {
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<AuthPasswordField
label='New password'
label={t('newPassword')}
id='password'
name='password'
autoComplete='new-password'
@@ -151,18 +152,18 @@ const UpdatePasswordView = () => {
/>
<AuthPasswordField
label='Confirm new password'
label={t('confirmNewPassword')}
id='passwordConfirm'
name='passwordConfirm'
autoComplete='new-password'
placeholder='Re-enter your password'
placeholder={t('reenterPassword')}
value={passwordConfirm}
error={passwordConfirmationError}
onChange={handlePasswordConfirmChange}
/>
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
Save password
{t('savePassword')}
</AuthSubmitButton>
<Button
@@ -174,7 +175,7 @@ const UpdatePasswordView = () => {
sx={authButtonSx}
onClick={() => navigate('/login')}
>
Cancel
{t('common:cancel')}
</Button>
</Box>
</AuthShell>

View File

@@ -38,16 +38,17 @@ import {
ButtonGroup,
Chip,
Divider,
Dropdown,
IconButton,
Input,
Menu,
MenuButton,
MenuItem,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import AppModal from '../../../components/common/AppModal'
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { FILTER_COLORS } from '../../../utils/Colors'
import Priorities from '../../../utils/Priorities'
@@ -229,9 +230,8 @@ const ChoreToolbar = ({
const [localSelections, setLocalSelections] = useState(defaultSelections())
const [savingFilter, setSavingFilter] = useState(false)
const [saveFilterName, setSaveFilterName] = useState('')
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
const [saveMenuOpen, setSaveMenuOpen] = useState(false)
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
const saveMenuRef = useRef(null)
const activeConditions = selectionsToConditions(localSelections)
// ── badge counts ─────────────────────────────────────────────────────────────
@@ -419,13 +419,13 @@ const ChoreToolbar = ({
}
setSavingFilter(false)
setSaveFilterName('')
setSaveMenuAnchorEl(null)
setSaveMenuOpen(false)
setFilterSheetOpen(true)
}
useEffect(() => {
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
setSaveMenuAnchorEl(null)
setSaveMenuOpen(false)
}
}, [filterSheetOpen, savingFilter, activeConditions.length])
@@ -504,7 +504,7 @@ const ChoreToolbar = ({
onFilterSaved?.(editingSavedFilter.name)
})
setSaveMenuAnchorEl(null)
setSaveMenuOpen(false)
setFilterSheetOpen(false)
}
@@ -681,7 +681,7 @@ const ChoreToolbar = ({
open={filterSheetOpen}
isMobile
onClose={() => {
setSaveMenuAnchorEl(null)
setSaveMenuOpen(false)
setFilterSheetOpen(false)
}}
maxHeight='92vh'
@@ -747,7 +747,7 @@ const ChoreToolbar = ({
disabled={!hasAnyActive && activeConditions.length === 0}
onClick={() => {
setLocalSelections(defaultSelections())
setSaveMenuAnchorEl(null)
setSaveMenuOpen(false)
onClearAllFilters?.()
setFilterSheetOpen(false)
}}
@@ -756,33 +756,29 @@ const ChoreToolbar = ({
</Button>
{activeConditions.length > 0 ? (
<>
<Dropdown
open={saveMenuOpen}
onOpenChange={(_event, isOpen) => setSaveMenuOpen(isOpen)}
>
<ButtonGroup variant='solid' color='primary'>
<Button
onClick={() => {
setSaveMenuAnchorEl(null)
setSaveMenuOpen(false)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
{resultCount != null ? `Show ${resultCount}` : 'Done'}
</Button>
<IconButton
ref={saveMenuRef}
<MenuButton
slots={{ root: IconButton }}
aria-label='More save options'
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
>
<ArrowDropDown />
</IconButton>
</MenuButton>
</ButtonGroup>
<Menu
anchorEl={saveMenuAnchorEl}
open={Boolean(saveMenuAnchorEl)}
onClose={() => setSaveMenuAnchorEl(null)}
placement='top-end'
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
>
<Menu placement='top-end'>
<MenuItem
onClick={handleUpdateFilter}
disabled={!editingSavedFilter}
@@ -792,7 +788,7 @@ const ChoreToolbar = ({
</MenuItem>
<MenuItem
onClick={() => {
setSaveMenuAnchorEl(null)
setSaveMenuOpen(false)
setSaveFilterName(
editingSavedFilter
? `${editingSavedFilter.name} Copy`
@@ -805,13 +801,13 @@ const ChoreToolbar = ({
Save as New Filter
</MenuItem>
</Menu>
</>
</Dropdown>
) : (
<Button
variant='solid'
color='primary'
onClick={() => {
setSaveMenuAnchorEl(null)
setSaveMenuOpen(false)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}

View File

@@ -18,6 +18,7 @@ import {
formatErrorReport,
} from '../service/ErrorReportService'
import ErrorReportModal from './Modals/ErrorReportModal'
import { useTranslation } from 'react-i18next'
const getErrorKind = error => {
if (!error)
@@ -55,6 +56,7 @@ const safeMessage = error => {
}
const Error = () => {
const { t } = useTranslation('common')
const error = useRouteError()
const [showDetails, setShowDetails] = useState(false)
const [copied, setCopied] = useState(false)
@@ -192,7 +194,7 @@ const Error = () => {
textAlign='center'
sx={{ mb: 1.5 }}
>
Something went wrong
{t('errorScreen.title')}
</Typography>
{/* Error message */}
@@ -207,8 +209,7 @@ const Error = () => {
wordBreak: 'break-word',
}}
>
{message ??
'An unexpected error occurred. Try reloading — it usually fixes it.'}
{message ?? t('errorScreen.fallback')}
</Typography>
{/* Primary CTA */}
@@ -220,7 +221,7 @@ const Error = () => {
onClick={() => window.location.reload()}
sx={{ width: '100%', mb: 1.5 }}
>
Try again
{t('errorScreen.tryAgain')}
</Button>
{/* Reporting is one tap from the failure, where the context is still
@@ -246,7 +247,7 @@ const Error = () => {
size='lg'
startDecorator={<HomeRounded />}
>
Home
{t('errorScreen.home')}
</Button>
<Button
component={Link}
@@ -255,7 +256,7 @@ const Error = () => {
color='neutral'
size='lg'
>
Login
{t('errorScreen.login')}
</Button>
</Box>
@@ -293,7 +294,9 @@ const Error = () => {
}
sx={{ mb: 1 }}
>
{showDetails ? 'Hide' : 'Show'} error details
{showDetails
? t('errorScreen.hideDetails')
: t('errorScreen.showDetails')}
</Button>
{showDetails && (
@@ -312,7 +315,7 @@ const Error = () => {
color='neutral'
onClick={handleCopy}
sx={{ position: 'absolute', top: 8, right: 8 }}
title='Copy to clipboard'
title={t('errorScreen.copyToClipboard')}
>
<ContentCopyRounded fontSize='small' />
</IconButton>
@@ -347,7 +350,7 @@ const Error = () => {
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
size='sm'
>
Error details copied to clipboard
{t('errorScreen.copied')}
</Snackbar>
</Box>
)

View File

@@ -1,10 +1,12 @@
import { Box, Button, Container, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import Logo from '../Logo'
const Home = () => {
const { t } = useTranslation('common')
const Navigate = useNavigate()
const getCurrentUser = () => {
return JSON.parse(localStorage.getItem('user'))
@@ -36,7 +38,7 @@ const Home = () => {
Navigate('/chores')
}}
>
Get Started!
{t('getStarted')}
</Button>
</Box>
</Container>

View File

@@ -1,4 +1,5 @@
import { Browser } from '@capacitor/browser'
import { useTranslation } from 'react-i18next'
import { Capacitor } from '@capacitor/core'
import { Download } from '@mui/icons-material'
import { Box, CircularProgress, Typography } from '@mui/joy'
@@ -29,6 +30,7 @@ const downloadUrl = (url, fileName) => {
}
function AttachmentViewerModal({ config }) {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal()
const [imgLoaded, setImgLoaded] = useState(false)
const [imgError, setImgError] = useState(false)
@@ -49,7 +51,7 @@ function AttachmentViewerModal({ config }) {
maxHeight='92vh'
footer={
<ModalActions
secondary={{ label: 'Close', onClick: handleClose }}
secondary={{ label: t('close'), onClick: handleClose }}
primary={{
label: 'Download',
startDecorator: <Download />,
@@ -73,7 +75,7 @@ function AttachmentViewerModal({ config }) {
)}
{imgError ? (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Failed to load image.
{t('imageLoadFailed')}
</Typography>
) : (
<Box

View File

@@ -2,8 +2,10 @@ import { Input } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function DateModal({ isOpen, onClose, onSave, current, title }) {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal()
const [date, setDate] = useState(
current ? new Date(current).toISOString().split('T')[0] : '',
@@ -22,8 +24,8 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
title={title}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
secondary={{ label: t('cancel'), onClick: onClose }}
primary={{ label: t('save'), onClick: handleSave, disabled: !date }}
/>
}
>

View File

@@ -2,6 +2,7 @@ import { Textarea } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function TextModal({
isOpen,
@@ -12,6 +13,7 @@ function TextModal({
okText,
cancelText,
}) {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal()
const [text, setText] = useState(current)
@@ -35,7 +37,7 @@ function TextModal({
>
<Textarea
autoFocus
placeholder='Type in here…'
placeholder={t('typeHere')}
value={text}
onChange={event => setText(event.target.value)}
minRows={3}

View File

@@ -1,8 +1,10 @@
import { Avatar, Box, List, ListItem, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
const { t } = useTranslation('common')
const { ResponsiveModal } = useResponsiveModal()
return (
@@ -13,7 +15,7 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
fullWidth={true}
title='Select User'
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
<ModalActions secondary={{ label: t('cancel'), onClick: onClose }} />
}
>
<List sx={{ mb: 2 }}>

View File

@@ -1,22 +1,4 @@
import {
AccountCircle,
Api,
BugReport,
ChevronRight,
Circle,
Code,
FamilyRestroom,
Feedback,
Language,
Notifications,
Palette,
Person,
Security,
Settings,
Star,
Storage,
ViewSidebar,
} from '@mui/icons-material'
import { BugReport, ChevronRight, Feedback, Star } from '@mui/icons-material'
import {
Avatar,
Box,
@@ -37,6 +19,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { SETTINGS_SECTIONS } from '../../constants/settingsSections'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import { isParentUser } from '../../utils/UserHelpers'
@@ -51,85 +34,13 @@ const SettingsOverview = () => {
const [bugReportOpen, setBugReportOpen] = useState(false)
const settingsCards = [
{
id: 'profile',
title: t('overview.sections.profile.title'),
description: t('overview.sections.profile.description'),
icon: <Person />,
},
{
id: 'circle',
title: t('overview.sections.circle.title'),
description: t('overview.sections.circle.description'),
icon: <Circle />,
},
{
id: 'account',
title: t('overview.sections.account.title'),
description: t('overview.sections.account.description'),
icon: <AccountCircle />,
},
{
id: 'subaccounts',
title: t('overview.sections.subaccounts.title'),
description: t('overview.sections.subaccounts.description'),
icon: <FamilyRestroom />,
},
{
id: 'notifications',
title: t('overview.sections.notifications.title'),
description: t('overview.sections.notifications.description'),
icon: <Notifications />,
},
{
id: 'mfa',
title: t('overview.sections.mfa.title'),
description: t('overview.sections.mfa.description'),
icon: <Security />,
},
{
id: 'apitokens',
title: t('overview.sections.apitokens.title'),
description: t('overview.sections.apitokens.description'),
icon: <Api />,
},
{
id: 'storage',
title: t('overview.sections.storage.title'),
description: t('overview.sections.storage.description'),
icon: <Storage />,
},
{
id: 'sidepanel',
title: t('overview.sections.sidepanel.title'),
description: t('overview.sections.sidepanel.description'),
icon: <ViewSidebar />,
},
{
id: 'theme',
title: t('overview.sections.theme.title'),
description: t('overview.sections.theme.description'),
icon: <Palette />,
},
{
id: 'localization',
title: t('overview.sections.localization.title'),
description: t('overview.sections.localization.description'),
icon: <Language />,
isBeta: true,
},
{
id: 'advanced',
title: t('overview.sections.advanced.title'),
description: t('overview.sections.advanced.description'),
icon: <Settings />,
},
{
id: 'developer',
title: t('overview.sections.developer.title'),
description: t('overview.sections.developer.description'),
icon: <Code />,
},
...SETTINGS_SECTIONS.map(({ icon: Icon, id, isBeta }) => ({
id,
title: t(`overview.sections.${id}.title`),
description: t(`overview.sections.${id}.description`),
icon: <Icon />,
isBeta,
})),
{
id: 'feedback',
title: t('overview.sections.feedback.title'),
@@ -154,23 +65,19 @@ const SettingsOverview = () => {
navigate(`/settings/${setting.id}`)
}
const parentOnlyIds = SETTINGS_SECTIONS.filter(
section => section.parentOnly,
).map(section => section.id)
// Filter settings based on user type
const getAvailableSettings = () => {
const parentOnlySettings = [
'children',
'mfa',
'apitokens',
'circle',
'account',
]
if (isParentUser(userProfile)) {
// Parent users can access all settings
return settingsCards
} else {
// Child users can only access basic settings
return settingsCards.filter(
setting => !parentOnlySettings.includes(setting.id),
setting => !parentOnlyIds.includes(setting.id),
)
}
}

View File

@@ -1,13 +1,15 @@
import { Card, IconButton, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
const SummaryCard = () => {
const { t } = useTranslation('common')
return (
<Card>
<div className='flex justify-between'>
<div>
<Typography level='h2'>Summary</Typography>
<Typography level='body-xs'>
This is a summary of your chores
{t('summaryOfChores')}
</Typography>
</div>
<IconButton>

View File

@@ -3,8 +3,6 @@ import { Add } from '@mui/icons-material'
import { Divider, Menu, MenuItem } from '@mui/joy'
import React, { useEffect } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const AutocompleteDropdown = ({
currentValue,
onCreateSuggestion, // Called when the "Create new" row is chosen
@@ -62,7 +60,6 @@ const AutocompleteDropdown = ({
position: 'relative',
bottom: 0,
left: 0,
zIndex: Z_INDEX.MODAL_POPOVER,
}}
>
{filteredOptions.map((option, index) => (

View File

@@ -1,5 +1,14 @@
import { Add } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy'
import { Add, KeyboardArrowDown } from '@mui/icons-material'
import {
Box,
Button,
Dropdown,
ListItemDecorator,
Menu,
MenuButton,
MenuItem,
Typography,
} from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import * as chrono from 'chrono-node'
@@ -20,6 +29,7 @@ import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import { CreateLabel } from '../../utils/Fetcher'
import { imageSourceToFile } from '../../utils/FileConvert'
import { isPlusAccount } from '../../utils/Helpers'
import { getIconComponent } from '../../utils/ProjectIcons'
import { generateUUID } from '../../utils/UUID'
import { useLabels } from '../Labels/LabelQueries'
import { useProjects } from '../Projects/ProjectQueries'
@@ -122,6 +132,13 @@ const getInitialProject = () => {
return 'default'
}
const DEFAULT_PROJECT = {
id: 'default',
name: 'Default Project',
color: '#9CA3AF',
icon: 'FolderOpen',
}
const PRIORITY_COLORS = {
0: TASK_COLOR.NO_PRIORITY,
1: TASK_COLOR.PRIORITY_1,
@@ -175,7 +192,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: circleMembers, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { isLoading: isProjectsLoading } = useProjects()
const { data: projects, isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore()
const queryClient = useQueryClient()
@@ -293,6 +310,17 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const [useCustomTime, setUseCustomTime] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject)
const selectedProject = useMemo(
() =>
(projectId !== 'default' &&
projects?.find(project => project.id === projectId)) ||
DEFAULT_PROJECT,
[projects, projectId],
)
const SelectedProjectIcon = useMemo(
() => getIconComponent(selectedProject.icon),
[selectedProject],
)
const [attachments, setAttachments] = useState([])
const [draftId, setDraftId] = useState(() => generateUUID())
@@ -1121,6 +1149,50 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
title='Create new task'
footer={
<ModalActions>
{!showScan && !showVoice && projects?.length >= 1 && (
<Dropdown>
<MenuButton
variant='plain'
color='neutral'
size='sm'
startDecorator={
<SelectedProjectIcon
sx={{ fontSize: 18, color: selectedProject.color }}
/>
}
endDecorator={<KeyboardArrowDown sx={{ fontSize: 16 }} />}
sx={{
mr: 'auto',
color: 'text.secondary',
fontWeight: 'normal',
}}
>
{selectedProject.name}
</MenuButton>
<Menu
placement='top-start'
sx={{ minWidth: 200, zIndex: Z_INDEX.MODAL_POPOVER }}
>
{[DEFAULT_PROJECT, ...projects].map(project => {
const ProjectIcon = getIconComponent(project.icon)
return (
<MenuItem
key={project.id}
selected={projectId === project.id}
onClick={() => setProjectId(project.id)}
>
<ListItemDecorator>
<ProjectIcon
sx={{ fontSize: 18, color: project.color }}
/>
</ListItemDecorator>
{project.name}
</MenuItem>
)
})}
</Menu>
</Dropdown>
)}
<Button
variant='outlined'
color='neutral'

View File

@@ -18,7 +18,6 @@ import {
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { useFileUpload } from '../../hooks/useFileUpload'
import { useNotification } from '../../service/NotificationProvider'
@@ -223,7 +222,7 @@ const AttachmentPickerField = ({
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
sx={{ zIndex: 'calc(var(--joy-zIndex-modal) + 1)' }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet

View File

@@ -1,7 +1,9 @@
import { Chip, List, ListItem, ListItemButton, Textarea } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import React, { useEffect, useRef, useState } from 'react'
const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
const { t } = useTranslation('common')
const [filteredOptions, setFilteredOptions] = useState([])
const [menuVisible, setMenuVisible] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
@@ -82,7 +84,7 @@ const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
value={value}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder='Type here...'
placeholder={t('autocompletePlaceholder')}
/>
{menuVisible && (
<List ref={menuRef} style={{ position: 'absolute', zIndex: 1000 }}>

View File

@@ -2,7 +2,6 @@ import { Close } from '@mui/icons-material'
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BaseOptionPicker = ({
items = [],
@@ -199,7 +198,7 @@ const BaseOptionPicker = ({
},
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
sx={{ zIndex: 'calc(var(--joy-zIndex-modal) + 1)' }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet

View File

@@ -1,31 +1,29 @@
import { Box, Button, CircularProgress, Container } from '@mui/joy'
import { Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Logo from '../../Logo'
import { networkManager } from '../../hooks/NetworkManager'
const LoadingComponent = () => {
const [message, setMessage] = useState('Loading...')
const { t } = useTranslation('common')
const [message, setMessage] = useState(t('loading'))
const [subMessage, setSubMessage] = useState('')
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
useEffect(() => {
if (!isOnline) {
setMessage('You are offline')
setSubMessage(
'This not available while offline. Please check your internet connection and try again.',
)
setMessage(t('loadingOffline'))
setSubMessage(t('loadingOfflineSub'))
}
}, [isOnline])
}, [isOnline, t])
useEffect(() => {
networkManager.registerNetworkListener(isOnline => setIsOnline(isOnline))
// if loading took more than 5 seconds update submessage to mention there might be an error:
const timeout = setTimeout(() => {
if (networkManager.isOnline) {
setSubMessage(
'This is taking longer than usual. There might be an issue.',
)
setSubMessage(t('loadingSlow'))
}
}, 5000)
return () => clearTimeout(timeout)
@@ -67,7 +65,7 @@ const LoadingComponent = () => {
window.location.href = '/' // navigate back to the home page
}}
>
Navigate Back
{t('navigateBack')}
</Button>
</Box>
</Container>

View File

@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core'
import {
Archive,
ArrowBack,
BugReport,
FilterAlt,
FolderOpen,
History,
@@ -30,10 +31,11 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { version } from '../../../package.json'
import UserProfileAvatar from '../../components/UserProfileAvatar'
import Z_INDEX from '../../constants/zIndex'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResource } from '../../queries/ResourceQueries'
import { useGlobalSearch } from '../../search/GlobalSearchContext'
import { apiClient } from '../../utils/ApiClient'
import ErrorReportModal from '../Modals/ErrorReportModal'
import NavBarLink from './NavBarLink'
import SyncStatusIndicator from './SyncStatusIndicator'
@@ -45,6 +47,7 @@ const NavBar = () => {
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
const [bugReportOpen, setBugReportOpen] = useState(false)
const links = [
{
@@ -206,7 +209,7 @@ const NavBar = () => {
? `calc(var(--safe-area-inset-top, 0px))`
: '',
position: 'sticky',
zIndex: Z_INDEX.NAVBAR,
zIndex: 'var(--joy-zIndex-popup)',
top: 0,
minHeight: '35px',
backgroundColor: 'var(--joy-palette-background-body)',
@@ -239,7 +242,6 @@ const NavBar = () => {
// height:
// 'calc(100vh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))',
overflow: 'auto',
zIndex: Z_INDEX.DRAWER,
},
}}
>
@@ -296,6 +298,17 @@ const NavBar = () => {
</ListItemDecorator>
<ListItemContent>Upgrade to Plus</ListItemContent>
</ListItemButton> */}
<ListItemButton
onClick={() => setBugReportOpen(true)}
sx={{
py: 1.2,
}}
>
<ListItemDecorator>
<BugReport />
</ListItemDecorator>
<ListItemContent>{t('navigation.reportBug')}</ListItemContent>
</ListItemButton>
<ListItemButton
onClick={() => {
apiClient.handleLogout()
@@ -329,6 +342,10 @@ const NavBar = () => {
</List>
</div>
</Drawer>
<ErrorReportModal
open={bugReportOpen}
onClose={() => setBugReportOpen(false)}
/>
</nav>
)
}

View File

@@ -1,4 +1,5 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Box from '@mui/joy/Box'
import ListItemDecorator from '@mui/joy/ListItemDecorator'
import Tabs from '@mui/joy/Tabs'
@@ -10,6 +11,7 @@ import Search from '@mui/icons-material/Search'
import Person from '@mui/icons-material/Person'
export default function NavBarMobile() {
const { t } = useTranslation('common')
const [index, setIndex] = React.useState(0)
const colors = ['primary', 'danger', 'success', 'warning']
return (
@@ -32,7 +34,7 @@ export default function NavBarMobile() {
>
<Tabs
size='lg'
aria-label='Bottom Navigation'
aria-label={t('bottomNav')}
value={index}
onChange={(event, value) => setIndex(value)}
sx={theme => ({
@@ -98,7 +100,7 @@ export default function NavBarMobile() {
<ListItemDecorator>
<Person />
</ListItemDecorator>
Profile
{t('profile')}
</Tab>
</TabList>
</Tabs>

View File

@@ -1,7 +1,7 @@
import { WifiOff } from '@mui/icons-material'
import { Alert, Box } from '@mui/joy'
import { useEffect, useState } from 'react'
import Z_INDEX from '../../constants/zIndex'
import { networkManager } from '../../hooks/NetworkManager'
const NetworkBanner = () => {
@@ -56,7 +56,7 @@ const NetworkBanner = () => {
paddingTop: `calc(var(--safe-area-inset-top, 0px))`,
top: 0,
left: 0,
zIndex: Z_INDEX.NETWORK_BANNER,
zIndex: 'var(--joy-zIndex-snackbar)',
pt: `calc( env(safe-area-inset-top, 0px))`,
width: '100%',
justifyContent: 'center',