Merge remote-tracking branch 'origin/develop' into analytics

# Conflicts:
#	public/locales/en/settings.json
#	src/views/Settings/DeveloperSettings.jsx
#	src/views/Settings/SettingsOverview.jsx
This commit is contained in:
Mo Tarbin
2026-08-14 01:26:51 -04:00
76 changed files with 1379 additions and 764 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",

View File

@@ -68,5 +68,51 @@
"results_other": "{{count}} Ergebnisse",
"typeToSearch": "Zum Suchen tippen"
}
},
"feedback": {
"later": "Vielleicht später",
"sentiment": {
"title": "Was hältst du von Donetick?",
"subtitle": "Ihre Antwort hilft uns zu entscheiden, was wir als Nächstes entwickeln sollen.",
"options": {
"love": "Ich liebe es",
"okay": "Es ist in Ordnung",
"issues": "Ich habe Probleme"
}
},
"categories": {
"bugs": "Fehler",
"missingFeature": "Fehlende Funktion",
"tooComplicated": "Zu kompliziert",
"slow": "Langsam",
"notifications": "Benachrichtigungen",
"ai": "KI",
"other": "Sonstiges"
},
"details": {
"title": "Was könnten wir verbessern?",
"messageLabel": "Weiteres Feedback",
"messagePlaceholder": "Was ist passiert oder was können wir verbessern?",
"contextNote": "Wir werden folgende Diagnosedaten einschließen, um Probleme reproduzieren zu können: App-Version, Gerätebeschreibung und Betriebssystem.",
"submit": "Feedback senden",
"contextNoteSelfHosted": "Du befindest dich auf einem selbst-gehosteten Instanz, daher wird keine Meldung von deinem Server erstellt. Wir eröffnen ein Github-Problem welches vorausgefüllt ist. Du selbst kannst es prüfen und bearbeiten bevor es erstellt wird.",
"submitSelfHosted": "Weiter zu GitHub"
},
"review": {
"title": "Schön, dass es Ihnen gefällt!",
"subtitle": "Eine Bewertung oder ein Stern hilft anderen Menschen Donetich zu finden.",
"github": "Als GitHub Favorit setzen",
"appStore": "Im App Store bewerten",
"playStore": "Auf Google Play bewerten"
},
"thanks": {
"title": "Vielen Dank für Ihr Feedback!",
"subtitle": "Wir lesen jede Antwort und diese entscheiden was wir als Nächstes tun."
},
"github": {
"title": "Melde es auf GitHub,",
"subtitle": "Wir haben ein Problem mit Ihren Notizen und Versionsdetails vorausgefüllt. Es wurde noch nichts verschickt überprüfe es und poste, sobald du bereit bist.",
"open": "Eröffne das Problem"
}
}
}

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

@@ -71,5 +71,11 @@
"reset": "Reset Timer",
"delete": "Delete Session"
}
},
"toolbar": {
"defaultProject": "Default Project"
},
"sort": {
"assignedToMe": "Assigned to me"
}
}

View File

@@ -29,7 +29,8 @@
"filters": "Filters",
"activities": "Activities",
"points": "Points",
"settings": "Settings"
"settings": "Settings",
"reportBug": "Report a Bug"
},
"search": {
"title": "Search",
@@ -121,5 +122,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

@@ -0,0 +1,17 @@
{
"builder": {
"errorName": "Please enter a filter name",
"errorConditions": "Please configure at least one filter condition",
"editTitle": "Edit Filter",
"newTitle": "New Filter",
"addConditions": "Add conditions to preview",
"save": "Save Filter",
"name": "Filter Name",
"namePlaceholder": "e.g. Overdue tasks for Alice",
"color": "Color"
},
"nameExists": "A filter with this name already exists",
"tasks_one": "{{count}} task",
"tasks_other": "{{count}} tasks",
"overdue": "{{count}} overdue"
}

View File

@@ -0,0 +1,89 @@
{
"edit": {
"title": "Edit History",
"completedDate": "Completed Date",
"note": "Note",
"additionalNotes": "Additional Notes",
"deleteTitle": "Delete History",
"deleteMessage": "Are you sure you want to delete this history?"
},
"status": {
"inProgress": "In Progress",
"completed": "Completed",
"skipped": "Skipped",
"pendingApproval": "Pending Approval",
"rejected": "Rejected",
"missed": "Missed",
"rescheduled": "Rescheduled",
"scheduled": "Scheduled"
},
"badge": {
"onTime": "On Time",
"onTimeLabel": "On time"
},
"detail": {
"title": "Activity Detail",
"openTask": "Open Task",
"editEntry": "Edit Entry",
"performedBy": "Performed by",
"assignedTo": "Assigned to",
"lastUpdated": "Last updated",
"duration": "Duration",
"pointsEarned": "Points earned",
"points_one": "{{count}} pt",
"points_other": "{{count}} pts",
"note": "Note"
},
"charts": {
"noData": "No data available",
"noLabels": "No Labels",
"unassigned": "Unassigned",
"unknownTask": "Unknown Task",
"late": "Late",
"status": {
"title": "Status",
"description": "Completed tasks status"
},
"due": {
"title": "Due Date",
"description": "Current tasks due date"
},
"priority": {
"title": "Priority",
"description": "Tasks by priority"
},
"labels": {
"title": "Labels",
"description": "Tasks by labels"
},
"labelsDuration": {
"title": "Labels (time)",
"description": "Time spent by labels (hours)"
},
"tasksTime": {
"title": "Tasks (time)",
"description": "Time spent by individual tasks (hours)"
},
"assigneeBreakdown": {
"title": "by Assignee",
"description": "Tasks grouped by assignee"
}
},
"filter": {
"status": "Status",
"pending": "Pending",
"labels": "Labels",
"hasNotes": "Has Notes",
"hasPoints": "Has Points",
"timePeriod": "Time Period",
"user": "User"
},
"period": {
"days_one": "{{count}} Day",
"days_other": "{{count}} Days",
"allTime": "All Time"
},
"activities": {
"title": "Activities"
}
}

View File

@@ -0,0 +1,9 @@
{
"shared": "Shared",
"delete": {
"title": "Delete Label",
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
},
"loadError": "Failed to load labels. Please try again.",
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task."
}

View File

@@ -0,0 +1,74 @@
{
"redeem": "Redeem Points",
"redeemModal": {
"redeemButton": "Redeem",
"pointsAvailable": "{{count}} points available",
"pointsToRedeem": "Points to Redeem",
"placeholder": "Enter points...",
"cannotExceed": "Cannot exceed available points",
"quickAdd": "Quick Add:",
"aboutToRedeem": "You are about to redeem",
"amount": "{{count}} points",
"remaining": "Remaining: {{count}} points"
},
"leaderboard": {
"titlePoints": "Points Leaderboard",
"titleTasks": "Tasks Leaderboard",
"subtitlePoints": "Rankings based on points earned during the selected time period",
"subtitleTasks": "Rankings based on tasks completed during the selected time period",
"modePoints": "Points",
"modeTasks": "Tasks",
"you": "You",
"tasksAndAvg": "{{tasks}} tasks • {{avg}} avg per task",
"available": "{{count}} available",
"points_one": "{{count}} point",
"points_other": "{{count}} points"
},
"tabs": {
"short7d": "7D",
"short6m": "6M",
"shortAll": "All",
"days7": "7 Days",
"months6": "6 Months",
"allTime": "All Time"
},
"filter": {
"analysisTitle": "Filter & Analysis",
"title": "Filter Points",
"showFor": "Show points for:",
"timePeriod": "Time period:"
},
"cards": {
"available": {
"title": "Available",
"subtext": "Ready to redeem"
},
"redeemed": {
"title": "Redeemed",
"subtext": "Previously used"
},
"total": {
"title": "Total",
"subtext": "All time earned"
},
"period": {
"title": "Period Points",
"allTime": "All time",
"last6Months": "Last 6 months",
"lastDays_one": "Last {{count}} day",
"lastDays_other": "Last {{count}} days"
}
},
"summary": {
"showingFor": "Showing points for",
"unknownUser": "Unknown User",
"overThe": "over the",
"allTime": "All Time",
"last6Months": "Last 6 Months",
"lastDays_one": "Last {{count}} Day",
"lastDays_other": "Last {{count}} Days"
},
"trend": {
"title": "Points Trend"
}
}

View File

@@ -0,0 +1,24 @@
{
"iconPicker": {
"chooseIcon": "Choose Project Icon",
"availableIcons": "Available Icons"
},
"defaultChip": "Default",
"tasks_one": "{{count}} task",
"tasks_other": "{{count}} tasks",
"shared": "Shared",
"delete": {
"title": "Delete Project",
"message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)."
},
"loadError": "Failed to load projects. Please try again.",
"blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.",
"defaultDescription": "All tasks without a specific project",
"selector": {
"title": "Projects",
"createNew": "Create New Project",
"createNewDescription": "Add a custom project workspace",
"manage": "Manage Projects",
"manageDescription": "View, edit, and organize all projects"
}
}

View File

@@ -503,5 +503,78 @@
"crashToggle": "Crash reports",
"crashHelper": "Send us the error and what led to it when Donetick crashes no task content.",
"footnote": "You can change these choices at any time."
},
"subscription": {
"sessionFailed": "Failed to create subscription session",
"errorTitle": "Subscription Error",
"errorMessage": "Failed to start subscription process. Please try again.",
"subscribe": "Subscribe",
"cancelAnytime": "Cancel anytime. No hidden fees. Secure payment powered by Stripe."
},
"childUsers": {
"errNameRequired": "Sub account name is required",
"errNameMin": "Sub account name must be at least 2 characters",
"errNameMax": "Sub account name must be less than 20 characters",
"errNameChars": "Sub account name can only contain lowercase letters, dot and dash",
"errPasswordRequired": "Password is required",
"errPasswordLength": "Password must be between 8 and 64 characters",
"errPasswordMatch": "Passwords do not match",
"errDisplayNameMax": "Display name must be less than 50 characters",
"createTitle": "Create Sub Account",
"createButton": "Create Account",
"nameLabel": "Sub Account Name *",
"namePlaceholder": "Enter sub account name (e.g., sarah)",
"displayNameLabel": "Display Name",
"displayNamePlaceholder": "Display name (optional, defaults to sub account name)",
"passwordLabel": "Password *",
"passwordPlaceholder": "Enter password (8-64 characters)",
"confirmPasswordLabel": "Confirm Password *",
"confirmPasswordPlaceholder": "Confirm password"
},
"passwordChange": {
"minLength": "Password must be at least 8 characters",
"maxLength": "Password must be less than 64 characters"
},
"payment": {
"cancelledTitle": "Payment has been cancelled",
"cancelledRedirect": "You will be redirected to the main page shortly.",
"successTitle": "Payment Successful!",
"successRedirect": "You will be redirected to the settings page shortly."
},
"developer": {
"tokenRefreshed": "Token refreshed successfully",
"endpointCalled": "Refresh token endpoint called successfully",
"clearDbCleared": "Local offline database cleared. Starting full sync...",
"clearDbDone": "Full sync completed from the beginning",
"intro": "View technical information about your authentication tokens and session",
"refreshCookieNote": "Refresh tokens are managed via HTTP-only cookies on web platform",
"noReconnectInfo": "No reconnection information available",
"noTimeoutInfo": "No timeout information available",
"noDebugInfo": "No debug information available"
},
"backup": {
"keyRequired": "Encryption key is required",
"created": "Backup created and downloaded successfully",
"createFailed": "Failed to create backup",
"selectFile": "Please select a backup file",
"restored": "Backup restored successfully. Please refresh the page.",
"restoreFailed": "Failed to restore backup",
"readFailed": "Failed to read backup file",
"createIntro": "Create an encrypted backup of your data. This backup will include all your chores, history, settings, and optionally your uploaded files.",
"keyPlaceholder": "Enter a strong encryption key",
"includeAssets": "Include uploaded files and assets",
"warningLabel": "Warning:",
"restoreWarning": "Restoring a backup will replace all your current data. This action cannot be undone.",
"restoreKeyPlaceholder": "Enter the encryption key used for this backup"
},
"deletion": {
"passwordRequired": "Please enter your password to continue",
"passwordAndDelete": "Please enter your password and type DELETE to confirm",
"failed": "Failed to delete account",
"passwordPlaceholder": "Enter your password",
"transferIntro": "You own circles that require ownership transfer before deletion. Please select new owners:",
"selectOwner": "Select new owner",
"confirmPrompt": "Please enter your password and type DELETE to confirm",
"typeDelete": "DELETE"
}
}

View File

@@ -0,0 +1,18 @@
{
"errName": "Name is required",
"errStateNumber": "State must be a number",
"errStateBool": "State must be true or false",
"errStateRequired": "State is required",
"name": "Name",
"namePlaceholder": "Thing name",
"value": "Value",
"valuePlaceholder": "Thing value",
"updateState": "Update state",
"update": "Update",
"history": {
"overview": "Things Overview",
"visualization": "Data Visualization",
"changeHistory": "Change History",
"updated": "Updated"
}
}

View File

@@ -0,0 +1,29 @@
{
"toast": {
"sessionUpdatedTitle": "Session updated",
"sessionUpdatedMessage": "Timer session has been updated successfully.",
"sessionUpdateFailTitle": "Failed to update session",
"tryAgain": "Please try again.",
"sessionUpdateErrorTitle": "Error updating session",
"sessionDeletedTitle": "Session deleted",
"sessionDeletedMessage": "Timer session has been deleted successfully.",
"sessionDeleteErrorTitle": "Error deleting session",
"deleteConfirmTitle": "Delete Timer Session",
"deleteConfirmMessage": "Are you sure you want to delete this timer session?"
},
"loading": "Loading timer data...",
"noData": "No timer data found for this chore.",
"activeWork": "Active Work",
"breakTime": "Break Time",
"workSessions": "Work Sessions",
"totalTime": "Total Time",
"distribution": "Work vs Break Distribution",
"noActiveTime": "No active time yet",
"sessionBreakdown": "Session Breakdown",
"sessions": "Sessions",
"addSession": "Add Session",
"startTime": "Start Time",
"endTime": "End Time",
"leaveEmpty": "Leave empty if session is ongoing",
"noSessionForChore": "No timer session found for this chore."
}

View File

@@ -73,7 +73,7 @@
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"subtitle": "Votre réponse nous aide à décider de ce qu'il faut construire ensuite.",
"options": {
"love": "Love it",
"okay": "It's okay",

View File

@@ -64,6 +64,7 @@
"footer": {
"navigate": "导航",
"open": "打开",
"results_one": "{{count}} result",
"results_other": "{{count}} 个结果",
"typeToSearch": "输入以搜索"
}

View File

@@ -113,21 +113,21 @@
"cancel": "取消"
},
"circleSettings": {
"title": "圈子设置",
"description": "当你创建或加入圈子时,账户会自动连接到该圈子。分享下方的专属圈子代码或链接即可轻松邀请好友。有人申请加入你的圈子时,下方会出现通知。",
"title": "Circle settings",
"description": "Your account is automatically connected to a Circle when you create or join one. Easily invite friends by sharing the unique Circle code or link below. You'll receive a notification below when someone requests to join your Circle. If you'd like to leave, simply hit the 'Leave Circle' button.",
"memberOf": "你是 {{name}} 的成员",
"yourCircleCode": "你的圈子代码是:",
"copyCode": "复制代码",
"shareInvite": "分享邀请",
"codeCopied": "代码已复制到剪贴板",
"linkCopied": "邀请链接已复制到剪贴板",
"codeCopied": "Circle code copied!",
"linkCopied": "Circle link copied!",
"myCircle": "我的圈子",
"shareTitle": "在 Donetick 加入 {{name}}",
"shareText": "我想邀请你加入 Donetick 上的 {{name}}。",
"shareDialogTitle": "分享圈子邀请",
"leave": "退出圈子",
"leaveConfirmTitle": "退出圈子",
"leaveConfirmMessage": "确定要退出你的圈子吗?",
"leaveConfirmMessage": "Are you sure you want to leave this circle?",
"leaveConfirmButton": "退出",
"leftCircle": "已成功退出圈子",
"leaveFailed": "退出圈子失败",
@@ -161,7 +161,7 @@
"joinOtherDescription": "想加入别人的圈子?向对方索取专属圈子代码或加入链接,然后在下方输入代码即可加入。",
"enterCircleCode": "输入圈子代码:",
"enterCodePlaceholder": "输入代码",
"joinCircle": "加入圈子",
"joinCircle": "Join a Circle",
"joinedPending": "已成功加入圈子,请等待圈主接受你的请求。",
"alreadyMember": "你已经是该圈子的成员",
"joinFailed": "加入圈子失败"
@@ -178,7 +178,7 @@
"freeDescription": "你当前使用的是免费套餐。升级到 Plus 可解锁更多功能。",
"upgrade": "升级",
"cancel": "取消订阅",
"password": "密码:",
"password": "Password",
"changePassword": "修改密码",
"passwordChanged": "密码修改成功",
"passwordChangeFailed": "密码修改失败",
@@ -390,7 +390,7 @@
"sidepanel": {
"title": "侧边栏自定义",
"heading": "侧边栏设置",
"description": "设置侧边栏显示哪些卡片以及它们的顺序。拖放可重新排序,也可切换每张卡片的显示状态。",
"description": "Customize the layout and visibility of cards in the sidepanel. This section is only available on large screen devices such as tablets and desktops.",
"resetToDefaults": "恢复默认",
"resetHelper": "这会将所有卡片的显示状态和顺序恢复为默认值。",
"cards": {
@@ -421,7 +421,7 @@
}
},
"theme": {
"title": "主题偏好",
"title": "Theme preferences",
"description": "选择站点的外观。可以固定使用某个主题,也可以与系统同步,在日间和夜间主题之间自动切换。",
"themeMode": "主题模式",
"light": "浅色",

View File

@@ -5,8 +5,10 @@ import AppModal from './common/AppModal'
import ModalActions from './common/ModalActions'
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
import { useTranslation } from 'react-i18next'
const SubscriptionModal = ({ open, onClose }) => {
const { t } = useTranslation('settings')
const [selectedPlan, setSelectedPlan] = useState('yearly')
const [isLoading, setIsLoading] = useState(false)
const { showError } = useNotification()
@@ -45,7 +47,7 @@ const SubscriptionModal = ({ open, onClose }) => {
const response = await GetSubscriptionSession()
if (!response.ok) {
throw new Error('Failed to create subscription session')
throw new Error(t('subscription.sessionFailed'))
}
const data = await response.json()
@@ -59,8 +61,8 @@ const SubscriptionModal = ({ open, onClose }) => {
} catch (error) {
console.error('Subscription error:', error)
showError({
title: 'Subscription Error',
message: 'Failed to start subscription process. Please try again.',
title: t('subscription.errorTitle'),
message: t('subscription.errorMessage'),
})
} finally {
setIsLoading(false)
@@ -71,7 +73,7 @@ const SubscriptionModal = ({ open, onClose }) => {
<AppModal
open={open}
onClose={onClose}
title='Upgrade to Plus'
title={t('overview.upgrade.title')}
description='Unlock reminders, rich task details, and advanced automation.'
size='lg'
closeOnBackdrop={!isLoading}
@@ -79,9 +81,9 @@ const SubscriptionModal = ({ open, onClose }) => {
footer={
<ModalActions
stackOnMobile
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
secondary={{ label: t('accountSettings.cancel'), onClick: onClose, disabled: isLoading }}
primary={{
label: 'Subscribe',
label: t('subscription.subscribe'),
onClick: handleSubscribe,
loading: isLoading,
}}
@@ -217,7 +219,7 @@ const SubscriptionModal = ({ open, onClose }) => {
color='neutral'
sx={{ textAlign: 'center', mt: 3 }}
>
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
{t('subscription.cancelAnytime')}
</Typography>
</AppModal>
)

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,36 @@
import {
AccountCircle,
Api,
Circle,
Code,
FamilyRestroom,
Language,
Notifications,
Palette,
Person,
PrivacyTip,
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: 'privacy', icon: PrivacyTip },
{ 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

@@ -22,7 +22,19 @@ i18n
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
ns: ['common', 'settings', 'chores'],
ns: [
'auth',
'common',
'settings',
'chores',
'labels',
'history',
'filters',
'things',
'projects',
'timer',
'points',
],
defaultNS: 'common',
detection: {

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
@@ -70,36 +72,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
}
@@ -125,14 +127,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',
})
})
@@ -143,7 +145,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.'
@@ -158,11 +160,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}
@@ -173,11 +175,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 => {
@@ -187,12 +189,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 => {
@@ -205,10 +207,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)
@@ -216,14 +219,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

@@ -11,6 +11,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import LabelModal from '../Modals/Inputs/LabelModal'
import {
@@ -32,6 +33,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useLabels } from './LabelQueries'
const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
const { t } = useTranslation('labels')
// Check if current user owns this label
const isOwnedByCurrentUser = label.created_by === currentUserId
@@ -124,7 +126,7 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
fontWeight: 'md',
}}
>
Shared
{t('shared')}
</Chip>
)}
</Box>
@@ -149,6 +151,7 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
}
const LabelView = () => {
const { t } = useTranslation('labels')
const { data: labels, isLabelsLoading, isError } = useLabels()
const { data: userProfile } = useUserProfile()
@@ -173,14 +176,11 @@ const LabelView = () => {
const handleDeleteClicked = id => {
setConfirmationModel({
isOpen: true,
title: 'Delete Label',
message:
'Are you sure you want to delete this label? This will remove the label from all tasks.',
confirmText: 'Delete',
title: t('delete.title'),
message: t('delete.message'),
confirmText: t('common:delete'),
color: 'danger',
cancelText: 'Cancel',
cancelText: t('common:cancel'),
onClose: confirmed => {
if (confirmed === true) {
handleDeleteLabel(id)
@@ -230,7 +230,7 @@ const LabelView = () => {
if (isError) {
return (
<Typography color='danger' textAlign='center'>
Failed to load labels. Please try again.
{t('loadError')}
</Typography>
)
}
@@ -244,12 +244,10 @@ const LabelView = () => {
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Labels
{t('common:navigation.labels')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Manage your labels and organize your tasks effectively. Labels will
be automatically shared with your circle if they are used on a
shared task.
{t('blurb')}
</Typography>
</Stack>
</Box>
@@ -300,7 +298,7 @@ const LabelView = () => {
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
{t('common:edit')}
</Typography>
</Box>
</SwipeAction>
@@ -319,7 +317,7 @@ const LabelView = () => {
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete
{t('common:delete')}
</Typography>
</Box>
</SwipeAction>

View File

@@ -1,12 +1,14 @@
import { FormLabel, Input } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import ConfirmationModal from './Inputs/ConfirmationModal'
function EditHistoryModal({ config, historyRecord }) {
const { t } = useTranslation('history')
const { ResponsiveModal } = useResponsiveModal()
const [completedDate, setCompletedDate] = useState('')
@@ -40,12 +42,12 @@ function EditHistoryModal({ config, historyRecord }) {
onClose={config?.onClose}
size='lg'
// fullWidth={true}
title='Edit History'
title={t('edit.title')}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: config.onClose }}
secondary={{ label: t('common:cancel'), onClick: config.onClose }}
primary={{
label: 'Save',
label: t('common:save'),
onClick: () =>
config.onSave({
id: historyRecord.id,
@@ -57,7 +59,7 @@ function EditHistoryModal({ config, historyRecord }) {
/>
}
>
<FormLabel>Due Date</FormLabel>
<FormLabel>{t('chores:dueDate')}</FormLabel>
<Input
type='datetime-local'
value={dueDate}
@@ -66,7 +68,7 @@ function EditHistoryModal({ config, historyRecord }) {
}}
sx={{ mb: 2 }}
/>
<FormLabel>Completed Date</FormLabel>
<FormLabel>{t('edit.completedDate')}</FormLabel>
<Input
type='datetime-local'
value={completedDate}
@@ -75,12 +77,12 @@ function EditHistoryModal({ config, historyRecord }) {
}}
sx={{ mb: 2 }}
/>
<FormLabel>Note</FormLabel>
<FormLabel>{t('edit.note')}</FormLabel>
<Input
fullWidth
multiline
label='Additional Notes'
placeholder='Additional Notes'
label={t('edit.additionalNotes')}
placeholder={t('edit.additionalNotes')}
value={notes}
onChange={e => {
if (e.target.value.trim() === '') {
@@ -104,10 +106,10 @@ function EditHistoryModal({ config, historyRecord }) {
}
setIsDeleteModalOpen(false)
},
title: 'Delete History',
message: 'Are you sure you want to delete this history?',
confirmText: 'Delete',
cancelText: 'Cancel',
title: t('edit.deleteTitle'),
message: t('edit.deleteMessage'),
confirmText: t('common:delete'),
cancelText: t('common:cancel'),
color: 'danger',
}}
/>

View File

@@ -14,6 +14,7 @@ import {
} from '@mui/icons-material'
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
import moment from 'moment'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext'
@@ -22,13 +23,13 @@ import { TASK_COLOR } from '../../utils/Colors.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
const STATUS_CONFIG = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
0: { label: t('status.inProgress'), color: 'primary', icon: <AccessTime /> },
1: { label: t('status.completed'), color: 'success', icon: <Check /> },
2: { label: t('status.skipped'), color: 'warning', icon: <Redo /> },
3: { label: t('status.pendingApproval'), color: 'neutral', icon: <HourglassEmpty /> },
4: { label: t('status.rejected'), color: 'danger', icon: <ThumbDown /> },
5: { label: t('status.missed'), color: 'danger', icon: <RunningWithErrors /> },
6: { label: t('status.rescheduled'), color: 'warning', icon: <Schedule /> },
}
const DetailRow = ({ icon, label, value, children }) => (
@@ -55,6 +56,8 @@ const DetailRow = ({ icon, label, value, children }) => (
)
const TimingBadge = ({ historyEntry }) => {
const { t } = useTranslation('history')
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
if ([0, 5, 6].includes(historyEntry.status)) return null
@@ -71,7 +74,7 @@ const TimingBadge = ({ historyEntry }) => {
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
startDecorator={<Check />}
>
On Time
{t('badge.onTime')}
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
@@ -103,6 +106,7 @@ const TimingBadge = ({ historyEntry }) => {
}
function HistoryDetailModal({ config }) {
const { t } = useTranslation('history')
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
const navigate = useNavigate()
@@ -114,7 +118,9 @@ function HistoryDetailModal({ config }) {
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
const isFirstSchedule = entry.status === 6 && !entry.dueDate
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
const statusLabel = isFirstSchedule
? t('status.scheduled')
: t(statusCfg.labelKey)
const performer = performers.find(p => p.userId === entry.completedBy)
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
const isDifferentAssignee =
@@ -138,7 +144,7 @@ function HistoryDetailModal({ config }) {
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
title='Activity Detail'
title={t('detail.title')}
footer={
<ModalActions>
{entry.choreId && (
@@ -151,7 +157,7 @@ function HistoryDetailModal({ config }) {
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
{t('detail.openTask')}
</Button>
)}
{config?.onEdit && (
@@ -159,7 +165,7 @@ function HistoryDetailModal({ config }) {
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
{t('detail.editEntry')}
</Button>
)}
</ModalActions>
@@ -196,7 +202,7 @@ function HistoryDetailModal({ config }) {
{performer && (
<DetailRow
icon={<Check sx={{ fontSize: 16 }} />}
label='Performed by'
label={t('detail.performedBy')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar
@@ -216,7 +222,7 @@ function HistoryDetailModal({ config }) {
{isDifferentAssignee && assignedTo && (
<DetailRow
icon={<Person sx={{ fontSize: 16 }} />}
label='Assigned to'
label={t('detail.assignedTo')}
value={assignedTo.displayName}
/>
)}
@@ -259,7 +265,7 @@ function HistoryDetailModal({ config }) {
{showUpdatedAt && (
<DetailRow
icon={<Update sx={{ fontSize: 16 }} />}
label='Last updated'
label={t('detail.lastUpdated')}
value={fmt.dateTime(entry.updatedAt)}
/>
)}
@@ -268,7 +274,7 @@ function HistoryDetailModal({ config }) {
{entry.duration > 0 && (
<DetailRow
icon={<Schedule sx={{ fontSize: 16 }} />}
label='Duration'
label={t('detail.duration')}
value={formatDuration(entry.duration)}
/>
)}
@@ -277,8 +283,8 @@ function HistoryDetailModal({ config }) {
{entry.points > 0 && (
<DetailRow
icon={<Typography sx={{ fontSize: 14 }}></Typography>}
label='Points earned'
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
label={t('detail.pointsEarned')}
value={t('detail.points', { count: entry.points })}
/>
)}

View File

@@ -11,6 +11,7 @@ import FilterBuilderContent, {
import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine'
import { useFilters } from '../../Filters/FilterQueries'
import { useTranslation } from 'react-i18next'
const EMPTY_FILTERS = []
@@ -25,6 +26,7 @@ const AdvancedFilterBuilder = ({
userProfile = null,
editingFilter = null,
}) => {
const { t } = useTranslation('filters')
const [filterName, setFilterName] = useState('')
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [selections, setSelections] = useState(defaultSelections())
@@ -76,15 +78,15 @@ const AdvancedFilterBuilder = ({
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
setError(t('builder.errorName'))
return
}
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
setError('A filter with this name already exists')
setError(t('nameExists'))
return
}
if (conditions.length === 0) {
setError('Please configure at least one filter condition')
setError(t('builder.errorConditions'))
return
}
onSave({
@@ -106,7 +108,7 @@ const AdvancedFilterBuilder = ({
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{editingFilter ? 'Edit Filter' : 'New Filter'}
{editingFilter ? t('builder.editTitle') : t('builder.newTitle')}
{activeConditionCount > 0 && (
<Chip size='sm' variant='solid' color='primary'>
{activeConditionCount} condition
@@ -129,17 +131,17 @@ const AdvancedFilterBuilder = ({
{conditions.length > 0 ? (
<>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} task{previewCount !== 1 ? 's' : ''}
{t('tasks', { count: previewCount })}
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
{t('overdue', { count: previewOverdueCount })}
</Chip>
)}
</>
) : (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Add conditions to preview
{t('builder.addConditions')}
</Typography>
)}
</Box>
@@ -147,7 +149,7 @@ const AdvancedFilterBuilder = ({
{/* Actions */}
<ModalActions>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
{t('common:cancel')}
</Button>
<Button
variant='solid'
@@ -155,7 +157,7 @@ const AdvancedFilterBuilder = ({
startDecorator={<Save sx={{ fontSize: 16 }} />}
onClick={handleSave}
>
Save Filter
{t('builder.save')}
</Button>
</ModalActions>
</Box>
@@ -168,10 +170,10 @@ const AdvancedFilterBuilder = ({
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Filter Name
{t('builder.name')}
</Typography>
<Input
placeholder='e.g. Overdue tasks for Alice'
placeholder={t('builder.namePlaceholder')}
value={filterName}
onChange={e => {
setFilterName(e.target.value)
@@ -193,13 +195,13 @@ const AdvancedFilterBuilder = ({
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Color
{t('builder.color')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{FILTER_COLORS.map(c => (
<Box
key={c.value}
title={c.name}
title={t(`common:colors.${c.key}`)}
onClick={() => setFilterColor(c.value)}
sx={{
width: 26,

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

@@ -11,12 +11,14 @@ import {
Tabs,
Typography,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const [activeTab, setActiveTab] = useState(0)
@@ -66,7 +68,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const handleCreateBackup = async () => {
if (!encryptionKey.trim()) {
setError('Encryption key is required')
setError(t('backup.keyRequired'))
return
}
@@ -95,7 +97,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
showNotification({
type: 'success',
message: 'Backup created and downloaded successfully',
message: t('backup.created'),
})
handleClose()
@@ -104,7 +106,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
setError(errorData.message || 'Failed to create backup')
}
} catch (err) {
setError('Failed to create backup')
setError(t('backup.createFailed'))
} finally {
setLoading(false)
}
@@ -120,12 +122,12 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const handleRestore = async () => {
if (!restoreEncryptionKey.trim()) {
setError('Encryption key is required')
setError(t('backup.keyRequired'))
return
}
if (!backupFile) {
setError('Please select a backup file')
setError(t('backup.selectFile'))
return
}
@@ -142,7 +144,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
if (response.ok) {
showNotification({
type: 'success',
message: 'Backup restored successfully. Please refresh the page.',
message: t('backup.restored'),
})
// Refresh the page after a short delay to allow user to see the message
@@ -156,20 +158,20 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
setError(errorData.message || 'Failed to restore backup')
}
} catch (err) {
setError('Failed to restore backup')
setError(t('backup.restoreFailed'))
} finally {
setLoading(false)
}
}
reader.onerror = () => {
setError('Failed to read backup file')
setError(t('backup.readFailed'))
setLoading(false)
}
reader.readAsText(backupFile)
} catch (err) {
setError('Failed to restore backup')
setError(t('backup.restoreFailed'))
setLoading(false)
}
}
@@ -198,8 +200,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const renderBackupTab = () => (
<Box>
<Typography level='body-md' mb={3}>
Create an encrypted backup of your data. This backup will include all
your chores, history, settings, and optionally your uploaded files.
{t('backup.createIntro')}
</Typography>
<FormControl sx={{ mb: 2 }}>
@@ -208,7 +209,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
type='password'
value={encryptionKey}
onChange={e => setEncryptionKey(e.target.value)}
placeholder='Enter a strong encryption key'
placeholder={t('backup.keyPlaceholder')}
/>
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Keep this key safeyou&apos;ll need it to restore your backup
@@ -228,7 +229,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
<Checkbox
checked={includeAssets}
onChange={e => setIncludeAssets(e.target.checked)}
label='Include uploaded files and assets'
label={t('backup.includeAssets')}
/>
</FormControl>
@@ -243,8 +244,8 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const renderRestoreTab = () => (
<Box>
<Typography level='body-md' mb={3} color='warning'>
<strong>Warning:</strong> Restoring a backup will replace all your
current data. This action cannot be undone.
<strong>{t('backup.warningLabel')}</strong>{' '}
{t('backup.restoreWarning')}
</Typography>
<FormControl sx={{ mb: 2 }}>
@@ -268,7 +269,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
type='password'
value={restoreEncryptionKey}
onChange={e => setRestoreEncryptionKey(e.target.value)}
placeholder='Enter the encryption key used for this backup'
placeholder={t('backup.restoreKeyPlaceholder')}
/>
</FormControl>
@@ -293,7 +294,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('accountSettings.cancel'),
onClick: handleClose,
disabled: loading,
}}

View File

@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const [childName, setChildName] = useState('')
@@ -19,39 +21,38 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
if (touched.childName) {
if (!childName.trim()) {
newErrors.childName = 'Sub account name is required'
newErrors.childName = t('childUsers.errNameRequired')
} else if (childName.length < 2) {
newErrors.childName = 'Sub account name must be at least 2 characters'
newErrors.childName = t('childUsers.errNameMin')
} else if (childName.length > 20) {
newErrors.childName = 'Sub account name must be less than 20 characters'
newErrors.childName = t('childUsers.errNameMax')
} else if (!/^[a-z.-]+$/.test(childName)) {
newErrors.childName =
'Sub account name can only contain lowercase letters, dot and dash'
newErrors.childName = t('childUsers.errNameChars')
}
}
if (touched.password) {
if (!password) {
newErrors.password = 'Password is required'
newErrors.password = t('childUsers.errPasswordRequired')
} else if (password.length < 8) {
newErrors.password = 'Password must be between 8 and 64 characters'
newErrors.password = t('childUsers.errPasswordLength')
} else if (password.length > 64) {
newErrors.password = 'Password must be between 8 and 64 characters'
newErrors.password = t('childUsers.errPasswordLength')
}
}
if (touched.confirmPassword) {
if (password !== confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match'
newErrors.confirmPassword = t('childUsers.errPasswordMatch')
}
}
if (touched.displayName && displayName.length > 50) {
newErrors.displayName = 'Display name must be less than 50 characters'
newErrors.displayName = t('childUsers.errDisplayNameMax')
}
setErrors(newErrors)
}, [childName, displayName, password, confirmPassword, touched])
}, [childName, displayName, password, confirmPassword, touched, t])
const handleSubmit = async () => {
setTouched({
@@ -101,7 +102,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<ResponsiveModal
open={isOpen}
onClose={handleClose}
title='Create Sub Account'
title={t('childUsers.createTitle')}
description='Create a login that can complete tasks assigned to this account.'
size='md'
closeOnBackdrop={!isSubmitting}
@@ -109,12 +110,12 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('accountSettings.cancel'),
onClick: handleClose,
disabled: isSubmitting,
}}
primary={{
label: 'Create Account',
label: t('childUsers.createButton'),
onClick: handleSubmit,
disabled: !isValid || isSubmitting,
loading: isSubmitting,
@@ -124,14 +125,14 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
>
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Sub Account Name *
{t('childUsers.nameLabel')}
</Typography>
<Input
required
fullWidth
id='childName'
name='childName'
placeholder='Enter sub account name (e.g., sarah)'
placeholder={t('childUsers.namePlaceholder')}
value={childName}
onChange={e => {
setChildName(e.target.value)
@@ -145,13 +146,13 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<FormControl error={!!errors.displayName} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Display Name
{t('childUsers.displayNameLabel')}
</Typography>
<Input
fullWidth
id='displayName'
name='displayName'
placeholder='Display name (optional, defaults to sub account name)'
placeholder={t('childUsers.displayNamePlaceholder')}
value={displayName}
onChange={e => {
setDisplayName(e.target.value)
@@ -165,7 +166,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<FormControl error={!!errors.password} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Password *
{t('childUsers.passwordLabel')}
</Typography>
<Input
required
@@ -173,7 +174,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
name='password'
type='password'
id='password'
placeholder='Enter password (8-64 characters)'
placeholder={t('childUsers.passwordPlaceholder')}
value={password}
onChange={e => {
setPassword(e.target.value)
@@ -185,7 +186,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<FormControl error={!!errors.confirmPassword} sx={{ mb: 3 }}>
<Typography level='body2' mb={1}>
Confirm Password *
{t('childUsers.confirmPasswordLabel')}
</Typography>
<Input
required
@@ -193,7 +194,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
name='confirmPassword'
type='password'
id='confirmPassword'
placeholder='Confirm password'
placeholder={t('childUsers.confirmPasswordPlaceholder')}
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value)

View File

@@ -10,8 +10,10 @@ import {
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
const { t } = useTranslation('things')
const { ResponsiveModal } = useResponsiveModal()
const [name, setName] = useState(currentThing?.name || '')
@@ -33,17 +35,17 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
const isValid = () => {
const newErrors = {}
if (!name || name.trim() === '') {
newErrors.name = 'Name is required'
newErrors.name = t('errName')
}
if (type === 'number' && isNaN(state)) {
newErrors.state = 'State must be a number'
newErrors.state = t('errStateNumber')
}
if (type === 'boolean' && !['true', 'false'].includes(state)) {
newErrors.state = 'State must be true or false'
newErrors.state = t('errStateBool')
}
if ((type === 'text' && !state) || state.trim() === '') {
newErrors.state = 'State is required'
newErrors.state = t('errStateRequired')
}
setErrors(newErrors)
@@ -67,7 +69,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('common:cancel'),
onClick: onClose,
}}
primary={{
@@ -78,9 +80,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
}
>
<FormControl>
<Typography>Name</Typography>
<Typography>{t('name')}</Typography>
<Textarea
placeholder='Thing name'
placeholder={t('namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
sx={{ minWidth: 300 }}
@@ -101,9 +103,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
</FormControl>
{type === 'text' && (
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
value={state || ''}
onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }}
@@ -113,9 +115,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
)}
{type === 'number' && (
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
type='number'
value={state || ''}
onChange={e => {

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,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
const { t } = useTranslation('things')
const { ResponsiveModal } = useResponsiveModal()
const [state, setState] = useState(currentThing?.state || '')
@@ -13,7 +15,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
const newErrors = {}
if (state.trim() === '') {
newErrors.state = 'State is required'
newErrors.state = t('errStateRequired')
}
setErrors(newErrors)
@@ -38,18 +40,18 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
open={isOpen}
onClose={onClose}
size='sm'
title='Update state'
title={t('updateState')}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Update', onClick: handleSave }}
secondary={{ label: t('common:cancel'), onClick: onClose }}
primary={{ label: t('update'), onClick: handleSave }}
/>
}
>
<FormControl>
<Typography>Value</Typography>
<Typography>{t('value')}</Typography>
<Input
placeholder='Thing value'
placeholder={t('valuePlaceholder')}
value={state || ''}
onChange={e => setState(e.target.value)}
sx={{ minWidth: 300 }}

View File

@@ -3,6 +3,7 @@ import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
import PROJECT_ICONS from '../../../utils/ProjectIcons'
import { useTranslation } from 'react-i18next'
const IconPickerModal = ({
isOpen,
@@ -11,6 +12,7 @@ const IconPickerModal = ({
currentIcon,
projectColor,
}) => {
const { t } = useTranslation('projects')
const { ResponsiveModal } = useResponsiveModal()
const handleIconClick = iconValue => {
@@ -25,13 +27,13 @@ const IconPickerModal = ({
size='lg'
fullWidth={true}
unmountDelay={250}
title='Choose Project Icon'
title={t('iconPicker.chooseIcon')}
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
<ModalActions secondary={{ label: t('common:cancel'), onClick: onClose }} />
}
>
<FormControl>
<FormLabel>Available Icons</FormLabel>
<FormLabel>{t('iconPicker.availableIcons')}</FormLabel>
<Grid
container
spacing={1}
@@ -85,7 +87,7 @@ const IconPickerModal = ({
lineHeight: 1.2,
}}
>
{iconData.name}
{t(`icons.${iconData.key}`)}
</Typography>
</Box>
</Grid>

View File

@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useTranslation } from 'react-i18next'
function PasswordChangeModal({ isOpen, onClose }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
@@ -17,13 +19,13 @@ function PasswordChangeModal({ isOpen, onClose }) {
if (password !== confirmPassword) {
setPasswordError('Passwords do not match')
} else if (password.length < 8) {
setPasswordError('Password must be at least 8 characters')
setPasswordError(t('passwordChange.minLength'))
} else if (password.length > 64) {
setPasswordError('Password must be less than 64 characters')
setPasswordError(t('passwordChange.maxLength'))
} else {
setPasswordError(null)
}
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched])
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched, t])
const handleAction = isConfirmed => onClose(isConfirmed ? password : null)
const canSubmit =
@@ -38,13 +40,13 @@ function PasswordChangeModal({ isOpen, onClose }) {
open={isOpen}
onClose={() => handleAction(false)}
size='sm'
title='Change Password'
title={t('accountSettings.changePassword')}
description='Choose a password between 8 and 64 characters.'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: () => handleAction(false) }}
secondary={{ label: t('accountSettings.cancel'), onClick: () => handleAction(false) }}
primary={{
label: 'Change Password',
label: t('accountSettings.changePassword'),
disabled: !canSubmit,
onClick: () => handleAction(true),
}}

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

@@ -12,6 +12,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocalization } from '../../../contexts/LocalizationContext'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
@@ -24,6 +25,7 @@ import {
import ConfirmationModal from './ConfirmationModal'
const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const { t } = useTranslation('timer')
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
@@ -181,8 +183,8 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{
onSuccess: () => {
showSuccess({
title: 'Session updated',
message: 'Timer session has been updated successfully.',
title: t('toast.sessionUpdatedTitle'),
message: t('toast.sessionUpdatedMessage'),
})
refetchTimer()
cancelEditingSession(sessionId)
@@ -190,15 +192,15 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
},
onError: () => {
showError({
title: 'Failed to update session',
message: 'Please try again.',
title: t('toast.sessionUpdateFailTitle'),
message: t('toast.tryAgain'),
})
},
},
)
} catch (error) {
showError({
title: 'Error updating session',
title: t('toast.sessionUpdateErrorTitle'),
message: error.message,
})
} finally {
@@ -213,15 +215,15 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{
onSuccess: () => {
showSuccess({
title: 'Session deleted',
message: 'Timer session has been deleted successfully.',
title: t('toast.sessionDeletedTitle'),
message: t('toast.sessionDeletedMessage'),
})
refetchTimer()
onTimerUpdate?.()
},
onError: error => {
showError({
title: 'Error deleting session',
title: t('toast.sessionDeleteErrorTitle'),
message: error.message,
})
},
@@ -235,10 +237,10 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const confirmDeleteSession = sessionId => {
setConfirmDeleteConfig({
isOpen: true,
title: 'Delete Timer Session',
message: 'Are you sure you want to delete this timer session?',
confirmText: 'Delete',
cancelText: 'Cancel',
title: t('toast.deleteConfirmTitle'),
message: t('toast.deleteConfirmMessage'),
confirmText: t('common:delete'),
cancelText: t('common:cancel'),
color: 'danger',
onClose: isConfirmed => {
if (isConfirmed) {
@@ -310,23 +312,23 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
tertiary={
!loading && timerData && !editingSessions[timerData.id]
? {
label: 'Delete',
label: t('common:delete'),
color: 'danger',
onClick: () => confirmDeleteSession(timerData.id),
}
: undefined
}
secondary={{ label: 'Close', onClick: handleClose }}
secondary={{ label: t('common:close'), onClick: handleClose }}
primary={
!loading && timerData
? editingSessions[timerData.id]
? {
label: 'Save',
label: t('common:save'),
onClick: () => saveSession(timerData.id),
loading,
}
: {
label: 'Edit',
label: t('common:edit'),
startDecorator: <Edit />,
onClick: () => startEditingSession(),
}
@@ -337,13 +339,13 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
>
{loading && (
<Alert color='neutral' sx={{ mb: 2 }}>
Loading timer data...
{t('loading')}
</Alert>
)}
{!loading && !timerData && (
<Alert color='warning' sx={{ mb: 2 }}>
No timer data found for this chore.
{t('noData')}
</Alert>
)}
@@ -403,7 +405,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Active Work
{t('activeWork')}
</Typography>
</Box>
<Box>
@@ -457,7 +459,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Break Time
{t('breakTime')}
</Typography>
</Box>
<Box>
@@ -511,7 +513,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Work Sessions
{t('workSessions')}
</Typography>
</Box>
<Box>
@@ -565,7 +567,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
color: 'text.primary',
}}
>
Total Time
{t('totalTime')}
</Typography>
</Box>
<Box>
@@ -597,12 +599,12 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
>
Work vs Break Distribution
{t('distribution')}
</Typography>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{calculateCurrentActiveDuration() > 0
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
: 'No active time yet'}
: t('noActiveTime')}
</Typography>
</Box>
<Box
@@ -630,7 +632,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{/* Time Session */}
<Box>
<Typography level='h4' sx={{ mb: 2 }}>
Session Breakdown
{t('sessionBreakdown')}
</Typography>
<Box>
@@ -644,7 +646,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-sm'
sx={{ fontWeight: 'bold', mb: 2 }}
>
Work Sessions ({timerData.pauseLog.length})
{t('workSessions')} ({timerData.pauseLog.length})
</Typography>
<Box
@@ -795,7 +797,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-sm'
sx={{ fontWeight: 'bold' }}
>
Sessions
{t('sessions')}
</Typography>
<Button
size='sm'
@@ -803,7 +805,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
startDecorator={<Add />}
onClick={() => addPauseLogEntry(timerData.id)}
>
Add Session
{t('addSession')}
</Button>
</Box>
@@ -855,7 +857,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ fontWeight: 'bold' }}
>
Start Time
{t('startTime')}
</Typography>
<Input
type='datetime-local'
@@ -878,7 +880,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
level='body-xs'
sx={{ fontWeight: 'bold' }}
>
End Time
{t('endTime')}
</Typography>
<Input
type='datetime-local'
@@ -903,7 +905,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
}
/>
<FormHelperText>
Leave empty if session is ongoing
{t('leaveEmpty')}
</FormHelperText>
</FormControl>
@@ -941,7 +943,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
{!timerData && (
<Alert color='neutral' sx={{ mt: 2 }}>
No timer session found for this chore.
{t('noSessionForChore')}
</Alert>
)}
</Box>

View File

@@ -14,8 +14,10 @@ import ModalActions from '../../../components/common/ModalActions'
import { useNavigate } from 'react-router-dom'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
import { useTranslation } from 'react-i18next'
function UserDeletionModal({ isOpen, onClose }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const Navigate = useNavigate()
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
@@ -47,7 +49,7 @@ function UserDeletionModal({ isOpen, onClose }) {
const checkDeletionRequirements = async () => {
if (password.trim() === '') {
setError('Please enter your password to continue')
setError(t('deletion.passwordRequired'))
return
}
@@ -97,7 +99,7 @@ function UserDeletionModal({ isOpen, onClose }) {
const executeUserDeletion = async () => {
if (password.trim() === '' || confirmation !== 'DELETE') {
setError('Please enter your password and type DELETE to confirm')
setError(t('deletion.passwordAndDelete'))
return
}
@@ -120,7 +122,7 @@ function UserDeletionModal({ isOpen, onClose }) {
}
} catch (err) {
console.error('Failed to delete account:', err)
setError('Failed to delete account')
setError(t('deletion.failed'))
} finally {
setLoading(false)
}
@@ -181,7 +183,7 @@ function UserDeletionModal({ isOpen, onClose }) {
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='Enter your password'
placeholder={t('deletion.passwordPlaceholder')}
/>
</FormControl>
@@ -196,7 +198,7 @@ function UserDeletionModal({ isOpen, onClose }) {
const renderTransferStep = () => (
<>
<Typography level='body-md' mb={3}>
You own circles that require ownership transfer before deletion. Please
{t('deletion.transferIntro')}
select new owners:
</Typography>
@@ -208,7 +210,7 @@ function UserDeletionModal({ isOpen, onClose }) {
<FormControl>
<FormLabel>New Owner</FormLabel>
<Select
placeholder='Select new owner'
placeholder={t('deletion.selectOwner')}
value={
transferOptions.find(t => t.circleId === circle.id)
?.newOwnerId || ''
@@ -237,8 +239,7 @@ function UserDeletionModal({ isOpen, onClose }) {
const renderConfirmationStep = () => (
<>
<Typography level='body-md' mb={3}>
Please enter your password and type <strong>DELETE</strong> to confirm
account deletion.
{t('deletion.confirmPrompt')}
</Typography>
<Typography level='body-sm' mb={2}>
on successful deletion, you will be logged out and redirected to the
@@ -251,7 +252,7 @@ function UserDeletionModal({ isOpen, onClose }) {
type='password'
value={password}
onChange={e => setPassword(e.target.value)}
placeholder='Enter your password'
placeholder={t('deletion.passwordPlaceholder')}
/>
</FormControl>
@@ -260,7 +261,7 @@ function UserDeletionModal({ isOpen, onClose }) {
<Input
value={confirmation}
onChange={e => setConfirmation(e.target.value)}
placeholder='DELETE'
placeholder={t('deletion.typeDelete')}
/>
</FormControl>
@@ -304,7 +305,7 @@ function UserDeletionModal({ isOpen, onClose }) {
<ModalActions
stackOnMobile
secondary={{
label: 'Cancel',
label: t('accountSettings.cancel'),
onClick: () => handleClose(false),
}}
primary={{

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

@@ -11,6 +11,7 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import ModalActions from '../../components/common/ModalActions.jsx'
@@ -18,6 +19,7 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
function RedeemPointsModal({ config }) {
const { t } = useTranslation('points')
const { ResponsiveModal } = useResponsiveModal()
const [points, setPoints] = useState(0)
@@ -56,12 +58,12 @@ function RedeemPointsModal({ config }) {
open={config?.isOpen}
onClose={config?.onClose}
size='md'
title='Redeem Points'
title={t('redeem')}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: config?.onClose }}
secondary={{ label: t('common:cancel'), onClick: config?.onClose }}
primary={{
label: 'Redeem',
label: t('redeemModal.redeemButton'),
startDecorator: <CreditCard />,
disabled: !canRedeem,
onClick: () =>
@@ -103,7 +105,7 @@ function RedeemPointsModal({ config }) {
startDecorator={<Toll />}
sx={{ mt: 0.5 }}
>
{config?.available || 0} points available
{t('redeemModal.pointsAvailable', { count: config?.available || 0 })}
</Chip>
</Box>
</Stack>
@@ -112,7 +114,7 @@ function RedeemPointsModal({ config }) {
{/* Points Input Section */}
<FormControl>
<FormLabel sx={{ fontWeight: 600, mb: 1 }}>
Points to Redeem
{t('redeemModal.pointsToRedeem')}
</FormLabel>
<Input
type='number'
@@ -124,7 +126,7 @@ function RedeemPointsModal({ config }) {
input: {
min: 0,
max: config?.available || 0,
placeholder: 'Enter points...',
placeholder: t('redeemModal.placeholder'),
},
}}
onChange={e => handlePointsChange(e.target.value)}
@@ -140,7 +142,7 @@ function RedeemPointsModal({ config }) {
/>
{points > config?.available && (
<Typography level='body-xs' sx={{ color: 'danger.500', mt: 0.5 }}>
Cannot exceed available points
{t('redeemModal.cannotExceed')}
</Typography>
)}
</FormControl>
@@ -148,7 +150,7 @@ function RedeemPointsModal({ config }) {
{/* Quick Selection Buttons */}
<Box>
<Typography level='body-sm' sx={{ fontWeight: 600, mb: 1.5 }}>
Quick Add:
{t('redeemModal.quickAdd')}
</Typography>
<Stack
direction='row'
@@ -199,19 +201,21 @@ function RedeemPointsModal({ config }) {
}}
>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
You are about to redeem
{t('redeemModal.aboutToRedeem')}
</Typography>
<Typography
level='h4'
sx={{ color: 'primary.600', fontWeight: 700 }}
>
{points} points
{t('redeemModal.amount', { count: points })}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'text.secondary', mt: 0.5 }}
>
Remaining: {(config?.available || 0) - points} points
{t('redeemModal.remaining', {
count: (config?.available || 0) - points,
})}
</Typography>
</Card>
)}

View File

@@ -1,9 +1,11 @@
import { Box, Container, Sheet, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
const PaymentCancelledView = () => {
const { t } = useTranslation('settings')
const navigate = useNavigate()
useEffect(() => {
@@ -37,10 +39,10 @@ const PaymentCancelledView = () => {
>
<Logo />
<Typography level='h2' sx={{ mt: 2, mb: 1 }}>
Payment has been cancelled
{t('payment.cancelledTitle')}
</Typography>
<Typography level='body-md' sx={{ mb: 2 }}>
You will be redirected to the main page shortly.
{t('payment.cancelledRedirect')}
</Typography>
</Sheet>
</Box>

View File

@@ -1,9 +1,11 @@
import { Box, Container, Sheet, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
const PaymentSuccessView = () => {
const { t } = useTranslation('settings')
const navigate = useNavigate()
useEffect(() => {
@@ -37,10 +39,10 @@ const PaymentSuccessView = () => {
>
<Logo />
<Typography level='h2' sx={{ mt: 2, mb: 1 }}>
Payment Successful!
{t('payment.successTitle')}
</Typography>
<Typography level='body-md' sx={{ mb: 2 }}>
You will be redirected to the settings page shortly.
{t('payment.successRedirect')}
</Typography>
</Sheet>
</Box>

View File

@@ -11,6 +11,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ProjectModal from '../Modals/Inputs/ProjectModal'
@@ -40,6 +41,7 @@ const ProjectCardContent = ({
onCardClick,
onToggleActions,
}) => {
const { t } = useTranslation('projects')
// Check if current user owns this project
const isOwnedByCurrentUser = project.created_by === currentUserId
const isDefaultProject = project.id === 'default'
@@ -144,7 +146,7 @@ const ProjectCardContent = ({
fontWeight: 'md',
}}
>
Default
{t('defaultChip')}
</Chip>
)}
</Typography>
@@ -178,7 +180,7 @@ const ProjectCardContent = ({
color: 'primary.500',
}}
>
{taskCount} tasks
{t('tasks', { count: taskCount })}
</Chip>
{!isOwnedByCurrentUser && !isDefaultProject && (
@@ -193,7 +195,7 @@ const ProjectCardContent = ({
fontWeight: 'md',
}}
>
Shared
{t('shared')}
</Chip>
)}
</Box>
@@ -218,6 +220,7 @@ const ProjectCardContent = ({
}
const ProjectView = () => {
const { t } = useTranslation('projects')
const { data: projects, isProjectsLoading, isError } = useProjects()
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
@@ -250,11 +253,11 @@ const ProjectView = () => {
const project = userProjects.find(p => p.id === id)
setConfirmationModel({
isOpen: true,
title: 'Delete Project',
message: `Are you sure you want to delete "${project?.name}"? This will remove the project but keep all tasks (they'll move to the Default Project).`,
confirmText: 'Delete',
title: t('delete.title'),
message: t('delete.message', { name: project?.name }),
confirmText: t('common:delete'),
color: 'danger',
cancelText: 'Cancel',
cancelText: t('common:cancel'),
onClose: confirmed => {
if (confirmed === true) {
handleDeleteProject(id)
@@ -347,7 +350,7 @@ const ProjectView = () => {
if (isError) {
return (
<Typography color='danger' textAlign='center'>
Failed to load projects. Please try again.
{t('loadError')}
</Typography>
)
}
@@ -360,11 +363,10 @@ const ProjectView = () => {
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Projects
{t('common:navigation.projects')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Organize your tasks into projects. Create custom workspaces to keep
your tasks organized and easily accessible.
{t('blurb')}
</Typography>
</Stack>
</Box>
@@ -378,8 +380,8 @@ const ProjectView = () => {
<ProjectCardContent
project={{
id: 'default',
name: 'Default Project',
description: 'All tasks without a specific project',
name: t('chores:toolbar.defaultProject'),
description: t('defaultDescription'),
icon: 'FolderOpen',
color: '#1976d2',
created_by: userProfile?.id,
@@ -389,7 +391,7 @@ const ProjectView = () => {
onCardClick={() =>
handleCardClick({
id: 'default',
name: 'Default Project',
name: t('chores:toolbar.defaultProject'),
icon: 'FolderOpen',
color: '#1976d2',
})
@@ -429,7 +431,7 @@ const ProjectView = () => {
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
{t('common:edit')}
</Typography>
</Box>
</SwipeAction>
@@ -450,7 +452,7 @@ const ProjectView = () => {
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete
{t('common:delete')}
</Typography>
</Box>
</SwipeAction>

View File

@@ -3,6 +3,7 @@ import { Refresh, Star, Token } from '@mui/icons-material'
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { networkManager } from '../../hooks/NetworkManager'
import useConfirmationModal from '../../hooks/useConfirmationModal'
@@ -29,6 +30,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import PolicyUpdateModal from '../Modals/PolicyUpdateModal'
const DeveloperSettings = () => {
const { t } = useTranslation('settings')
const queryClient = useQueryClient()
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
const { data: userProfile } = useUserProfile()
@@ -240,7 +242,7 @@ const DeveloperSettings = () => {
if (result.success) {
showNotification({
type: 'success',
message: 'Token refreshed successfully',
message: t('developer.tokenRefreshed'),
})
// Reload token expiry data
@@ -276,7 +278,7 @@ const DeveloperSettings = () => {
const data = await response.json()
showNotification({
type: 'success',
message: 'Refresh token endpoint called successfully',
message: t('developer.endpointCalled'),
})
// Reload token expiry data
@@ -345,7 +347,7 @@ const DeveloperSettings = () => {
showNotification({
type: 'success',
message: 'Local offline database cleared. Starting full sync...',
message: t('developer.clearDbCleared'),
})
const didSync = await syncEngine.sync()
@@ -353,7 +355,7 @@ const DeveloperSettings = () => {
await queryClient.invalidateQueries()
showNotification({
type: 'success',
message: 'Full sync completed from the beginning',
message: t('developer.clearDbDone'),
})
} else {
showNotification({
@@ -450,7 +452,7 @@ const DeveloperSettings = () => {
<Typography level='h3'>Developer Settings</Typography>
<Divider />
<Typography level='body-md'>
View technical information about your authentication tokens and session
{t('developer.intro')}
state. This information is useful for debugging and development
purposes.
</Typography>
@@ -548,7 +550,7 @@ const DeveloperSettings = () => {
</>
) : (
<Typography level='body-sm' color='neutral'>
Refresh tokens are managed via HTTP-only cookies on web platform
{t('developer.refreshCookieNote')}
</Typography>
)}
</Box>
@@ -1152,7 +1154,7 @@ const DeveloperSettings = () => {
</Box>
) : (
<Typography level='body-sm' color='neutral'>
No reconnection information available
{t('developer.noReconnectInfo')}
</Typography>
)}
</Box>
@@ -1192,7 +1194,7 @@ const DeveloperSettings = () => {
</Box>
) : (
<Typography level='body-sm' color='neutral'>
No timeout information available
{t('developer.noTimeoutInfo')}
</Typography>
)}
</Box>
@@ -1237,7 +1239,7 @@ const DeveloperSettings = () => {
</Box>
) : (
<Typography level='body-sm' color='neutral'>
No debug information available
{t('developer.noDebugInfo')}
</Typography>
)}
</Box>

View File

@@ -1,23 +1,4 @@
import {
AccountCircle,
Api,
BugReport,
ChevronRight,
Circle,
Code,
FamilyRestroom,
Feedback,
Language,
Notifications,
Palette,
Person,
PrivacyTip,
Security,
Settings,
Star,
Storage,
ViewSidebar,
} from '@mui/icons-material'
import { BugReport, ChevronRight, Feedback, Star } from '@mui/icons-material'
import {
Avatar,
Box,
@@ -38,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'
@@ -52,91 +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: 'privacy',
title: t('overview.sections.privacy.title'),
description: t('overview.sections.privacy.description'),
icon: <PrivacyTip />,
},
{
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'),
@@ -161,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

@@ -10,6 +10,7 @@ import {
TrendingUp,
Update,
} from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
import {
Avatar,
Box,
@@ -41,6 +42,7 @@ import { useThingHistory } from '../../queries/ThingQueries'
import LoadingComponent from '../components/Loading'
const ThingsHistory = () => {
const { t } = useTranslation('things')
const { id } = useParams()
const theme = useTheme()
const { fmt } = useLocalization()
@@ -186,7 +188,7 @@ const ThingsHistory = () => {
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Things Overview
{t('history.overview')}
</Typography>
</Box>
@@ -266,7 +268,7 @@ const ThingsHistory = () => {
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Data Visualization
{t('history.visualization')}
</Typography>
</Box>
)}
@@ -328,7 +330,7 @@ const ThingsHistory = () => {
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Change History
{t('history.changeHistory')}
</Typography>
</Box>
<Box sx={{ borderRadius: 'sm', p: 1, boxShadow: 'md' }}>
@@ -379,7 +381,7 @@ const ThingsHistory = () => {
display: { xs: 'none', sm: 'block' },
}}
>
Updated
{t('history.updated')}
</Typography>
<Chip

View File

@@ -48,14 +48,21 @@ import { useLabels } from '../Labels/LabelQueries'
import { ChoresGrouper } from '../../utils/Chores'
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
import LoadingComponent from '../components/Loading'
import { useTranslation } from 'react-i18next'
const groupByDate = history => {
const aggregated = {}
for (let i = 0; i < history.length; i++) {
const item = history[i]
const date = new Date(
item.performedAt || item.updatedAt,
).toLocaleDateString()
// Key by a stable local ISO day (YYYY-MM-DD) so the render-time
// formatter (fmt.date) receives a parseable date instead of a
// locale-formatted string, which produced "Invalid date".
const d = new Date(item.performedAt || item.updatedAt || item.createdAt)
const date = isNaN(d.getTime())
? 'unknown'
: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate(),
).padStart(2, '0')}`
if (!aggregated[date]) {
aggregated[date] = []
}
@@ -83,6 +90,7 @@ const ChoreHistoryItem = ({
onViewNote,
onViewDetails,
}) => {
const { t } = useTranslation('history')
const cfg = statusConfig[status] ?? statusConfig[1]
return (
@@ -136,7 +144,7 @@ const ChoreHistoryItem = ({
</Typography>
{points && (
<Chip size='sm' color='success' startDecorator={<Toll />}>
{`${points} points`}
{t('detail.points', { count: points })}
</Chip>
)}
{notes && (
@@ -151,7 +159,7 @@ const ChoreHistoryItem = ({
onViewNote?.(notes)
}}
>
Note
{t('detail.note')}
</Chip>
)}
</Box>
@@ -174,7 +182,7 @@ const ChoreHistoryTimeline = ({
{Object.entries(groupedHistory).map(([date, items]) => (
<Box key={date} sx={{ mb: 4 }}>
<Typography level='title-sm' sx={{ mb: 0.5 }}>
{fmt.date(date)}
{date === 'unknown' ? '—' : fmt.date(date)}
</Typography>
<Divider />
<Stack spacing={1}>
@@ -197,7 +205,7 @@ const ChoreHistoryTimeline = ({
)
}
const renderPieChart = (data, size, isPrimary, chartType = null) => {
const renderPieChart = (t, data, size, isPrimary, chartType = null) => {
// Filter out items with zero or negative values
const validData = data.filter(item => item.value > 0)
@@ -216,7 +224,7 @@ const renderPieChart = (data, size, isPrimary, chartType = null) => {
}}
>
<Typography level='body-sm' color='neutral'>
No data available
{t('charts.noData')}
</Typography>
</Box>
)
@@ -400,6 +408,7 @@ const USER_FILTER = (history, userId) => {
}
const UserActivites = () => {
const { t } = useTranslation('history')
const { data: userProfile } = useUserProfile()
const [tabValue, setTabValue] = React.useState(7)
@@ -448,16 +457,16 @@ const UserActivites = () => {
() => [
{
id: 'status',
label: 'Status',
label: t('filter.status'),
type: 'multi-select',
icon: <Checklist />,
options: [
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
{ value: 1, label: t('status.completed'), color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: 2, label: t('status.skipped'), color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: 3, label: t('filter.pending'), color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: 4, label: t('status.rejected'), color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: t('status.missed'), color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: t('status.rescheduled'), color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
@@ -465,7 +474,7 @@ const UserActivites = () => {
? [
{
id: 'label',
label: 'Labels',
label: t('filter.labels'),
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
@@ -492,20 +501,20 @@ const UserActivites = () => {
: []),
{
id: 'hasNotes',
label: 'Has Notes',
label: t('filter.hasNotes'),
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'hasPoints',
label: 'Has Points',
label: t('filter.hasPoints'),
type: 'boolean',
icon: <Toll />,
filterFn: item => (item.points ?? 0) > 0,
},
],
[userLabels],
[userLabels, t],
)
const {
@@ -520,20 +529,20 @@ const UserActivites = () => {
() => [
{
id: 'timePeriod',
label: 'Time Period',
label: t('filter.timePeriod'),
type: 'single-select',
icon: <CalendarMonth />,
defaultValue: 7,
options: [
{ value: 7, label: '7 Days' },
{ value: 30, label: '30 Days' },
{ value: 90, label: '90 Days' },
{ value: 365, label: 'All Time' },
{ value: 7, label: t('period.days', { count: 7 }) },
{ value: 30, label: t('period.days', { count: 30 }) },
{ value: 90, label: t('period.days', { count: 90 }) },
{ value: 365, label: t('period.allTime') },
],
},
{
id: 'completedBy',
label: 'User',
label: t('filter.user'),
type: 'single-select',
icon: <Person />,
options: circleUsers.map(u => ({
@@ -544,7 +553,7 @@ const UserActivites = () => {
},
...clientFilterDefs,
],
[circleUsers, clientFilterDefs],
[circleUsers, clientFilterDefs, t],
)
// Merge server-driven and client-driven active filter states for the bar
@@ -686,7 +695,7 @@ const UserActivites = () => {
// Add unlabeled tasks if there are any
if (unlabeledCount > 0) {
result.push({
label: 'No Labels',
label: t('charts.noLabels'),
value: unlabeledCount,
color: TASK_COLOR.ANYTIME,
id: 'unlabeled',
@@ -709,7 +718,9 @@ const UserActivites = () => {
const assignee = circleUsers.find(
user => user.userId === chore.assignedTo,
)
const assigneeName = assignee ? assignee.displayName : 'Unassigned'
const assigneeName = assignee
? assignee.displayName
: t('charts.unassigned')
const assigneeId = chore.assignedTo || 'unassigned'
if (assigneeCounts[assigneeId]) {
@@ -802,7 +813,7 @@ const UserActivites = () => {
// Add unlabeled tasks duration if there is any
if (unlabeledDuration > 0) {
result.push({
label: 'No Labels',
label: t('charts.noLabels'),
value: Math.round((unlabeledDuration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal
color: TASK_COLOR.ANYTIME,
id: 'unlabeled',
@@ -823,7 +834,7 @@ const UserActivites = () => {
// Iterate through ChoreHistory to get actual time spent per task
history.forEach(historyItem => {
const duration = historyItem.duration || 0 // duration in seconds from ChoreHistory
const taskName = historyItem.choreName || 'Unknown Task'
const taskName = historyItem.choreName || t('charts.unknownTask')
if (taskDurations[taskName]) {
taskDurations[taskName].duration += duration
@@ -886,7 +897,7 @@ const UserActivites = () => {
if (totalCompleted > 0) {
result.push({
label: `On time`,
label: t('badge.onTimeLabel'),
value: totalCompleted,
color: TASK_COLOR.COMPLETED,
id: 1,
@@ -895,7 +906,7 @@ const UserActivites = () => {
if (totalLate > 0) {
result.push({
label: `Late`,
label: t('charts.late'),
value: totalLate,
color: TASK_COLOR.LATE,
id: 2,
@@ -904,7 +915,7 @@ const UserActivites = () => {
if (totalNoDueDate > 0) {
result.push({
label: `Completed`,
label: t('status.completed'),
value: totalNoDueDate,
color: TASK_COLOR.ANYTIME,
id: 3,
@@ -919,43 +930,43 @@ const UserActivites = () => {
const chartData = {
history: {
data: historyPieChartData || [],
title: 'Status',
description: 'Completed tasks status',
title: t('charts.status.title'),
description: t('charts.status.description'),
},
due: {
data: choreDuePieChartData || [],
title: 'Due Date',
description: 'Current tasks due date',
title: t('charts.due.title'),
description: t('charts.due.description'),
},
// assigned: {
// data: choresAssignedChartData,
// title: 'Assigned to me',
// title: t('chores:sort.assignedToMe'),
// description: 'Tasks assigned to you vs others',
// },
priority: {
data: choresPriorityChartData || [],
title: 'Priority',
description: 'Tasks by priority',
title: t('charts.priority.title'),
description: t('charts.priority.description'),
},
labels: {
data: choresLabelsChartData || [],
title: 'Labels',
description: 'Tasks by labels',
title: t('charts.labels.title'),
description: t('charts.labels.description'),
},
labelsDuration: {
data: choresLabelsDurationChartData || [],
title: 'Labels (time)',
description: 'Time spent by labels (hours)',
title: t('charts.labelsDuration.title'),
description: t('charts.labelsDuration.description'),
},
tasksTime: {
data: tasksTimeChartData || [],
title: 'Tasks (time)',
description: 'Time spent by individual tasks (hours)',
title: t('charts.tasksTime.title'),
description: t('charts.tasksTime.description'),
},
assigneeBreakdown: {
data: choresAssigneeBreakdownChartData || [],
title: 'by Assignee',
description: 'Tasks grouped by assignee',
title: t('charts.assigneeBreakdown.title'),
description: t('charts.assigneeBreakdown.description'),
},
}
if (!userProfile) {
@@ -976,7 +987,7 @@ const UserActivites = () => {
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Activities
{t('activities.title')}
</Typography>
</Box>
@@ -1025,7 +1036,7 @@ const UserActivites = () => {
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
title: 'Note',
title: t('detail.note'),
content: notes,
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
@@ -1153,6 +1164,7 @@ const UserActivites = () => {
}}
>
{renderPieChart(
t,
chartData[selectedChart].data,
300, // Increased size for better chart container
true,
@@ -1216,7 +1228,7 @@ const UserActivites = () => {
alignItems: 'center',
}}
>
{renderPieChart(data, 70, false)}
{renderPieChart(t, data, 70, false)}
</Box>
</Card>
</Grid>

View File

@@ -40,6 +40,8 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import moment from 'moment'
import { useTranslation } from 'react-i18next'
import LoadingComponent from '../components/Loading.jsx'
import { useChoresHistory } from '../../queries/ChoreQueries.jsx'
@@ -48,6 +50,7 @@ import { RedeemPoints } from '../../utils/Fetcher.jsx'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
import RedeemPointsModal from '../Modals/RedeemPointsModal'
const UserPoints = () => {
const { t } = useTranslation('points')
const [tabValue, setTabValue] = useState(7)
const [isRedeemModalOpen, setIsRedeemModalOpen] = useState(false)
const [leaderboardMode, setLeaderboardMode] = useState('points') // 'points' or 'tasks'
@@ -104,15 +107,13 @@ const UserPoints = () => {
const currentDate = new Date()
currentDate.setDate(currentDate.getDate() - i)
daysAggregated.push({
label: currentDate.toLocaleString('en-US', { weekday: 'short' }),
label: moment(currentDate).format('ddd'),
points: 0,
tasks: 0,
})
}
history.forEach(chore => {
const dayName = new Date(chore.performedAt).toLocaleString('en-US', {
weekday: 'short',
})
const dayName = moment(chore.performedAt).format('ddd')
const dayIndex = daysAggregated.findIndex(dayData => {
if (userId)
@@ -133,15 +134,13 @@ const UserPoints = () => {
const currentDate = new Date()
currentDate.setDate(currentDate.getDate() - i)
daysAggregated.push({
label: currentDate.toLocaleString('en-US', { day: 'numeric' }),
label: moment(currentDate).format('D'),
points: 0,
tasks: 0,
})
}
history.forEach(chore => {
const dayName = new Date(chore.performedAt).toLocaleString('en-US', {
day: 'numeric',
})
const dayName = moment(chore.performedAt).format('D')
const dayIndex = daysAggregated.findIndex(dayData => {
if (userId)
@@ -164,15 +163,13 @@ const UserPoints = () => {
const currentMonth = new Date()
currentMonth.setMonth(currentMonth.getMonth() - i)
monthlyAggregated.push({
label: currentMonth.toLocaleString('en-US', { month: 'short' }),
label: moment(currentMonth).format('MMM'),
points: 0,
tasks: 0,
})
}
history.forEach(chore => {
const monthName = new Date(chore.performedAt).toLocaleString('en-US', {
month: 'short',
})
const monthName = moment(chore.performedAt).format('MMM')
const monthIndex = monthlyAggregated.findIndex(monthData => {
if (userId)
@@ -195,15 +192,13 @@ const UserPoints = () => {
const currentYear = new Date()
currentYear.setFullYear(currentYear.getFullYear() - i)
yearlyAggregated.push({
label: currentYear.toLocaleString('en-US', { year: 'numeric' }),
label: moment(currentYear).format('YYYY'),
points: 0,
tasks: 0,
})
}
history.forEach(chore => {
const yearName = new Date(chore.performedAt).toLocaleString('en-US', {
year: 'numeric',
})
const yearName = moment(chore.performedAt).format('YYYY')
const yearIndex = yearlyAggregated.findIndex(yearData => {
if (userId)
@@ -305,14 +300,14 @@ const UserPoints = () => {
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
{leaderboardMode === 'points' ? 'Points' : 'Tasks'} Leaderboard
{leaderboardMode === 'points'
? t('leaderboard.titlePoints')
: t('leaderboard.titleTasks')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Rankings based on{' '}
{leaderboardMode === 'points'
? 'points earned'
: 'tasks completed'}{' '}
during the selected time period
? t('leaderboard.subtitlePoints')
: t('leaderboard.subtitleTasks')}
</Typography>
</Stack>
</Box>
@@ -358,9 +353,9 @@ const UserPoints = () => {
}}
>
{[
{ label: '7D', value: 7 },
{ label: '6M', value: 6 * 30 },
{ label: 'All', value: 24 * 30 },
{ label: t('tabs.short7d'), value: 7 },
{ label: t('tabs.short6m'), value: 6 * 30 },
{ label: t('tabs.shortAll'), value: 24 * 30 },
].map((tab, index) => (
<Tab
key={index}
@@ -408,7 +403,7 @@ const UserPoints = () => {
sx={{ cursor: 'pointer' }}
onClick={() => setLeaderboardMode('points')}
>
Points
{t('leaderboard.modePoints')}
</Chip>
<SwapHoriz
sx={{ fontSize: '0.875rem', color: 'text.tertiary' }}
@@ -420,7 +415,7 @@ const UserPoints = () => {
sx={{ cursor: 'pointer' }}
onClick={() => setLeaderboardMode('tasks')}
>
Tasks
{t('leaderboard.modeTasks')}
</Chip>
</Box>
</Box>
@@ -512,7 +507,7 @@ const UserPoints = () => {
color='primary'
sx={{ ml: 1 }}
>
You
{t('leaderboard.you')}
</Chip>
)}
</Typography>
@@ -520,8 +515,10 @@ const UserPoints = () => {
level='body-xs'
sx={{ color: 'text.secondary' }}
>
{user.periodTasks} tasks {user.avgPointsPerTask} avg
per task
{t('leaderboard.tasksAndAvg', {
tasks: user.periodTasks,
avg: user.avgPointsPerTask,
})}
</Typography>
</Stack>
</Box>
@@ -552,8 +549,12 @@ const UserPoints = () => {
</Box>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{leaderboardMode === 'points'
? `${user.availablePoints} available`
: `${user.periodPoints} points`}
? t('leaderboard.available', {
count: user.availablePoints,
})
: t('leaderboard.points', {
count: user.periodPoints,
})}
</Typography>
</Stack>
@@ -591,7 +592,7 @@ const UserPoints = () => {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
Filter & Analysis
{t('filter.analysisTitle')}
</Typography>
</Box>
@@ -610,7 +611,7 @@ const UserPoints = () => {
>
<Stack spacing={2}>
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
Filter Points
{t('filter.title')}
</Typography>
<Stack
@@ -621,7 +622,7 @@ const UserPoints = () => {
{/* User Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Show points for:
{t('filter.showFor')}
</Typography>
<Select
sx={{
@@ -688,7 +689,7 @@ const UserPoints = () => {
{/* Time Period Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Time period:
{t('filter.timePeriod')}
</Typography>
<Tabs
onChange={(e, tabValue) => {
@@ -713,9 +714,9 @@ const UserPoints = () => {
}}
>
{[
{ label: '7 Days', value: 7 },
{ label: '6 Months', value: 6 * 30 },
{ label: 'All Time', value: 24 * 30 },
{ label: t('tabs.days7'), value: 7 },
{ label: t('tabs.months6'), value: 6 * 30 },
{ label: t('tabs.allTime'), value: 24 * 30 },
].map((tab, index) => (
<Tab
key={index}
@@ -759,7 +760,7 @@ const UserPoints = () => {
}}
sx={{ mt: 'auto' }}
>
Redeem Points
{t('redeem')}
</Button>
</Box>
)}
@@ -788,27 +789,32 @@ const UserPoints = () => {
const pointsCards = [
{
icon: <Toll />,
title: 'Available',
text: `${availablePoints} points`,
subtext: 'Ready to redeem',
title: t('cards.available.title'),
text: t('leaderboard.points', { count: availablePoints }),
subtext: t('cards.available.subtext'),
},
{
icon: <Redeem />,
title: 'Redeemed',
text: `${redeemedPoints} points`,
subtext: 'Previously used',
title: t('cards.redeemed.title'),
text: t('leaderboard.points', { count: redeemedPoints }),
subtext: t('cards.redeemed.subtext'),
},
{
icon: <AccountBalanceWallet />,
title: 'Total',
text: `${totalPoints} points`,
subtext: 'All time earned',
title: t('cards.total.title'),
text: t('leaderboard.points', { count: totalPoints }),
subtext: t('cards.total.subtext'),
},
{
icon: <TrendingUp />,
title: 'Period Points',
text: `${periodPoints} points`,
subtext: `${tabValue === 24 * 30 ? 'All time' : tabValue === 6 * 30 ? 'Last 6 months' : `Last ${tabValue} days`}`,
title: t('cards.period.title'),
text: t('leaderboard.points', { count: periodPoints }),
subtext:
tabValue === 24 * 30
? t('cards.period.allTime')
: tabValue === 6 * 30
? t('cards.period.last6Months')
: t('cards.period.lastDays', { count: tabValue }),
},
]
@@ -871,24 +877,24 @@ const UserPoints = () => {
{/* Current Filter Summary */}
<Box sx={{ mb: 3, textAlign: 'center' }}>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Showing points for{' '}
{t('summary.showingFor')}{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{circleUsers.find(user => user.userId === selectedUser)
?.displayName || 'Unknown User'}
?.displayName || t('summary.unknownUser')}
</Typography>{' '}
over the{' '}
{t('summary.overThe')}{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{tabValue === 24 * 30
? 'All Time'
? t('summary.allTime')
: tabValue === 6 * 30
? 'Last 6 Months'
: `Last ${tabValue} Days`}
? t('summary.last6Months')
: t('summary.lastDays', { count: tabValue })}
</Typography>
</Typography>
</Box>
@@ -908,7 +914,7 @@ const UserPoints = () => {
level='h4'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Points Trend
{t('trend.title')}
</Typography>
</Box>

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()
@@ -298,6 +315,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())
@@ -1131,6 +1159,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',

View File

@@ -10,6 +10,7 @@ import {
MenuItem,
Typography,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
@@ -25,6 +26,7 @@ const ProjectSelector = ({
onProjectSelect,
showKeyboardShortcuts = false,
}) => {
const { t } = useTranslation('projects')
const { data: projects = [], isLoading } = useProjects()
const navigate = useNavigate()
@@ -265,7 +267,7 @@ const ProjectSelector = ({
</ListItemDecorator>
<ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
Projects
{t('selector.title')}
</Typography>
</ListItemContent>
</MenuItem>
@@ -339,7 +341,7 @@ const ProjectSelector = ({
: 'var(--joy-palette-text-primary)',
}}
>
Default Project
{t('chores:toolbar.defaultProject')}
</Typography>
{effectiveSelectedProject === 'Default Project' && (
<Check
@@ -477,13 +479,13 @@ const ProjectSelector = ({
fontWeight: 500,
}}
>
Create New Project
{t('selector.createNew')}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Add a custom project workspace
{t('selector.createNewDescription')}
</Typography>
</ListItemContent>
</MenuItem>
@@ -512,13 +514,13 @@ const ProjectSelector = ({
fontWeight: 500,
}}
>
Manage Projects
{t('selector.manage')}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
View, edit, and organize all projects
{t('selector.manageDescription')}
</Typography>
</ListItemContent>
</MenuItem>