diff --git a/package-lock.json b/package-lock.json index 4e3888f..0415b44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7a79373..74ed4c1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/de/common.json b/public/locales/de/common.json index 3a05b70..20c0c0a 100644 --- a/public/locales/de/common.json +++ b/public/locales/de/common.json @@ -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" + } } } diff --git a/public/locales/en/auth.json b/public/locales/en/auth.json new file mode 100644 index 0000000..28cdec1 --- /dev/null +++ b/public/locales/en/auth.json @@ -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." + } +} diff --git a/public/locales/en/chores.json b/public/locales/en/chores.json index ec82037..50b151f 100644 --- a/public/locales/en/chores.json +++ b/public/locales/en/chores.json @@ -71,5 +71,11 @@ "reset": "Reset Timer", "delete": "Delete Session" } + }, + "toolbar": { + "defaultProject": "Default Project" + }, + "sort": { + "assignedToMe": "Assigned to me" } } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 15752a7..af1f291 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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" } } diff --git a/public/locales/en/filters.json b/public/locales/en/filters.json new file mode 100644 index 0000000..6b10047 --- /dev/null +++ b/public/locales/en/filters.json @@ -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" +} diff --git a/public/locales/en/history.json b/public/locales/en/history.json new file mode 100644 index 0000000..1452849 --- /dev/null +++ b/public/locales/en/history.json @@ -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" + } +} diff --git a/public/locales/en/labels.json b/public/locales/en/labels.json new file mode 100644 index 0000000..93f96cb --- /dev/null +++ b/public/locales/en/labels.json @@ -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." +} diff --git a/public/locales/en/points.json b/public/locales/en/points.json new file mode 100644 index 0000000..a6ccd40 --- /dev/null +++ b/public/locales/en/points.json @@ -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" + } +} diff --git a/public/locales/en/projects.json b/public/locales/en/projects.json new file mode 100644 index 0000000..0e7eed4 --- /dev/null +++ b/public/locales/en/projects.json @@ -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" + } +} diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index a748179..44c90d3 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -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" } } diff --git a/public/locales/en/things.json b/public/locales/en/things.json new file mode 100644 index 0000000..1181eb5 --- /dev/null +++ b/public/locales/en/things.json @@ -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" + } +} diff --git a/public/locales/en/timer.json b/public/locales/en/timer.json new file mode 100644 index 0000000..848fd53 --- /dev/null +++ b/public/locales/en/timer.json @@ -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." +} diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json index 286fd96..d8c05b0 100644 --- a/public/locales/fr/common.json +++ b/public/locales/fr/common.json @@ -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", diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json index 2f55f6b..258c263 100644 --- a/public/locales/zh/common.json +++ b/public/locales/zh/common.json @@ -64,6 +64,7 @@ "footer": { "navigate": "导航", "open": "打开", + "results_one": "{{count}} result", "results_other": "{{count}} 个结果", "typeToSearch": "输入以搜索" } diff --git a/public/locales/zh/settings.json b/public/locales/zh/settings.json index 8ac26b1..c342420 100644 --- a/public/locales/zh/settings.json +++ b/public/locales/zh/settings.json @@ -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": "浅色", diff --git a/src/components/SubscriptionModal.jsx b/src/components/SubscriptionModal.jsx index fcd95b5..e5fe314 100644 --- a/src/components/SubscriptionModal.jsx +++ b/src/components/SubscriptionModal.jsx @@ -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 }) => { { footer={ { color='neutral' sx={{ textAlign: 'center', mt: 3 }} > - Cancel anytime. No hidden fees. Secure payment powered by Stripe. + {t('subscription.cancelAnytime')} ) diff --git a/src/components/animations/LoadingScreen.jsx b/src/components/animations/LoadingScreen.jsx index 6ee18c9..743e7e2 100644 --- a/src/components/animations/LoadingScreen.jsx +++ b/src/components/animations/LoadingScreen.jsx @@ -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 ( @@ -82,7 +85,7 @@ const LoadingScreen = ({ mb: 1, }} > - Done + {t('done')} tick @@ -96,7 +99,7 @@ const LoadingScreen = ({ }} /> - {message} + {message ?? t('loading')} ) diff --git a/src/components/common/AppModal.jsx b/src/components/common/AppModal.jsx index 55fefc6..72c84a7 100644 --- a/src/components/common/AppModal.jsx +++ b/src/components/common/AppModal.jsx @@ -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 diff --git a/src/components/common/filter/ActiveFilterChips.jsx b/src/components/common/filter/ActiveFilterChips.jsx index 7978629..faef0df 100644 --- a/src/components/common/filter/ActiveFilterChips.jsx +++ b/src/components/common/filter/ActiveFilterChips.jsx @@ -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')} )} diff --git a/src/constants/settingsSections.js b/src/constants/settingsSections.js new file mode 100644 index 0000000..7e52e50 --- /dev/null +++ b/src/constants/settingsSections.js @@ -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.`, 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 }, +] diff --git a/src/constants/zIndex.js b/src/constants/zIndex.js deleted file mode 100644 index 59ab5e1..0000000 --- a/src/constants/zIndex.js +++ /dev/null @@ -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 diff --git a/src/hooks/useConfirmationModal.js b/src/hooks/useConfirmationModal.js index 674ef47..516f0ca 100644 --- a/src/hooks/useConfirmationModal.js +++ b/src/hooks/useConfirmationModal.js @@ -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({ diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js index ec84b7e..f4430b4 100644 --- a/src/hooks/useFileUpload.js +++ b/src/hooks/useFileUpload.js @@ -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) } diff --git a/src/i18n/config.js b/src/i18n/config.js index 698fc83..f2a0064 100644 --- a/src/i18n/config.js +++ b/src/i18n/config.js @@ -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: { diff --git a/src/search/GlobalSearchContext.jsx b/src/search/GlobalSearchContext.jsx index 3ebae49..eabc6ed 100644 --- a/src/search/GlobalSearchContext.jsx +++ b/src/search/GlobalSearchContext.jsx @@ -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 = '') => { diff --git a/src/search/searchProviders.js b/src/search/searchProviders.js index 7b61a54..13f5288 100644 --- a/src/search/searchProviders.js +++ b/src/search/searchProviders.js @@ -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}`, }), diff --git a/src/service/AlertsProvider.jsx b/src/service/AlertsProvider.jsx index 470c36a..62c2afd 100644 --- a/src/service/AlertsProvider.jsx +++ b/src/service/AlertsProvider.jsx @@ -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', }} > diff --git a/src/views/Authorization/AuthFields.jsx b/src/views/Authorization/AuthFields.jsx index be3ca93..30c308d 100644 --- a/src/views/Authorization/AuthFields.jsx +++ b/src/views/Authorization/AuthFields.jsx @@ -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 }) => ( ) -export const AuthDivider = ({ children = 'or' }) => ( - - - {children} - - -) +export const AuthDivider = ({ children }) => { + const { t } = useTranslation('auth') + + return ( + + + {children ?? t('or')} + + + ) +} export const LegalLinks = () => ( { + 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')} )} @@ -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')) }} /> diff --git a/src/views/Authorization/ForgotPasswordView.jsx b/src/views/Authorization/ForgotPasswordView.jsx index 6bd73a9..de6e0e8 100644 --- a/src/views/Authorization/ForgotPasswordView.jsx +++ b/src/views/Authorization/ForgotPasswordView.jsx @@ -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 ( } logoSize={0} @@ -101,7 +103,7 @@ const ForgotPasswordView = () => { sx={authButtonSx} onClick={() => navigate('/login')} > - Back to sign in + {t('backToSignIn')} @@ -110,7 +112,7 @@ const ForgotPasswordView = () => { return ( } 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')} diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx index 6cd1d3d..3087c3d 100644 --- a/src/views/Authorization/LoginSettings.jsx +++ b/src/views/Authorization/LoginSettings.jsx @@ -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 ( { sx={{ display: 'flex', flexDirection: 'column' }} > { startDecorator={} sx={{ mt: 2, borderRadius: '12px' }} > - Connected. Taking you to sign in... + {t('serverConnected')} )} @@ -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')} ) diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index 32e7a37..ab5e492 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -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 = () => { Navigate('/login/settings')} > @@ -465,7 +467,7 @@ const LoginView = () => { {displayName} {getUserDisplayInfo(userProfile).userType === 'child' && ( - Sub Account + {t('subAccount')} )} @@ -486,7 +488,7 @@ const LoginView = () => { sx={authButtonSx} onClick={() => apiClient.handleLogout()} > - Use a different account + {t('useDifferentAccount')} ) : ( @@ -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') }, ]} /> {loginType === 'primary' ? ( setUsername(e.target.value)} @@ -519,20 +521,20 @@ const LoginView = () => { ) : ( <> setParentUsername(e.target.value)} /> 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')} @@ -568,7 +571,7 @@ const LoginView = () => { )} - {hasSocialOptions && or continue with} + {hasSocialOptions && {t('orContinueWith')}} {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')} )} diff --git a/src/views/Authorization/MFAVerificationModal.jsx b/src/views/Authorization/MFAVerificationModal.jsx index 8261301..6bf4011 100644 --- a/src/views/Authorization/MFAVerificationModal.jsx +++ b/src/views/Authorization/MFAVerificationModal.jsx @@ -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={ - {isBackupCode ? 'Backup code' : 'Verification code'} + {isBackupCode ? t('mfaModal.backupLabel') : t('mfaModal.codeLabel')} 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')} @@ -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')} )} diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx index 905516a..debe00f 100644 --- a/src/views/Authorization/Signup.jsx +++ b/src/views/Authorization/Signup.jsx @@ -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 ( { sx={{ display: 'flex', flexDirection: 'column', gap: 2 }} > { /> { @@ -187,12 +189,12 @@ const SignupView = () => { /> { @@ -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 = () => { /> - Create account + {t('createAccountButton')} - By creating an account you agree to our Terms of Service and Privacy + {t('termsShort')} Policy. diff --git a/src/views/Authorization/UpdatePasswordView.jsx b/src/views/Authorization/UpdatePasswordView.jsx index 0122e7c..bb672ec 100644 --- a/src/views/Authorization/UpdatePasswordView.jsx +++ b/src/views/Authorization/UpdatePasswordView.jsx @@ -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 ( } showLogo @@ -106,7 +107,7 @@ const UpdatePasswordView = () => { sx={authButtonSx} onClick={() => navigate('/forgot-password')} > - Request a new link + {t('requestNewLink')} @@ -125,7 +126,7 @@ const UpdatePasswordView = () => { return ( } // 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 }} > { /> - Save password + {t('savePassword')} diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index 29960b4..cd4e28d 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -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 = ({ {activeConditions.length > 0 ? ( - <> + setSaveMenuOpen(isOpen)} + > - setSaveMenuAnchorEl(e.currentTarget)} > - + - setSaveMenuAnchorEl(null)} - placement='top-end' - sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }} - > + { - setSaveMenuAnchorEl(null) + setSaveMenuOpen(false) setSaveFilterName( editingSavedFilter ? `${editingSavedFilter.name} Copy` @@ -805,13 +801,13 @@ const ChoreToolbar = ({ Save as New Filter - + ) : ( {/* Reporting is one tap from the failure, where the context is still @@ -246,7 +247,7 @@ const Error = () => { size='lg' startDecorator={} > - Home + {t('errorScreen.home')} @@ -293,7 +294,9 @@ const Error = () => { } sx={{ mb: 1 }} > - {showDetails ? 'Hide' : 'Show'} error details + {showDetails + ? t('errorScreen.hideDetails') + : t('errorScreen.showDetails')} {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')} > @@ -347,7 +350,7 @@ const Error = () => { anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} size='sm' > - Error details copied to clipboard + {t('errorScreen.copied')} ) diff --git a/src/views/Home.jsx b/src/views/Home.jsx index 894e7a7..5600c1b 100644 --- a/src/views/Home.jsx +++ b/src/views/Home.jsx @@ -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')} diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 1f05020..841215e 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -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')} )} @@ -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 ( - Failed to load labels. Please try again. + {t('loadError')} ) } @@ -244,12 +244,10 @@ const LabelView = () => { level='h3' sx={{ fontWeight: 'lg', color: 'text.primary' }} > - Labels + {t('common:navigation.labels')} - 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')} @@ -300,7 +298,7 @@ const LabelView = () => { > - Edit + {t('common:edit')} @@ -319,7 +317,7 @@ const LabelView = () => { > - Delete + {t('common:delete')} diff --git a/src/views/Modals/EditHistoryModal.jsx b/src/views/Modals/EditHistoryModal.jsx index 6321ea6..381828c 100644 --- a/src/views/Modals/EditHistoryModal.jsx +++ b/src/views/Modals/EditHistoryModal.jsx @@ -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={ config.onSave({ id: historyRecord.id, @@ -57,7 +59,7 @@ function EditHistoryModal({ config, historyRecord }) { /> } > - Due Date + {t('chores:dueDate')} - Completed Date + {t('edit.completedDate')} - Note + {t('edit.note')} { 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', }} /> diff --git a/src/views/Modals/HistoryDetailModal.jsx b/src/views/Modals/HistoryDetailModal.jsx index 23400c6..2744314 100644 --- a/src/views/Modals/HistoryDetailModal.jsx +++ b/src/views/Modals/HistoryDetailModal.jsx @@ -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: }, - 1: { label: 'Completed', color: 'success', icon: }, - 2: { label: 'Skipped', color: 'warning', icon: }, - 3: { label: 'Pending Approval', color: 'neutral', icon: }, - 4: { label: 'Rejected', color: 'danger', icon: }, - 5: { label: 'Missed', color: 'danger', icon: }, - 6: { label: 'Rescheduled', color: 'warning', icon: }, + 0: { label: t('status.inProgress'), color: 'primary', icon: }, + 1: { label: t('status.completed'), color: 'success', icon: }, + 2: { label: t('status.skipped'), color: 'warning', icon: }, + 3: { label: t('status.pendingApproval'), color: 'neutral', icon: }, + 4: { label: t('status.rejected'), color: 'danger', icon: }, + 5: { label: t('status.missed'), color: 'danger', icon: }, + 6: { label: t('status.rescheduled'), color: 'warning', icon: }, } 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={} > - On Time + {t('badge.onTime')} ) } 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 }) { {entry.choreId && ( @@ -151,7 +157,7 @@ function HistoryDetailModal({ config }) { navigate(`/chores/${entry.choreId}`) }} > - Open Task + {t('detail.openTask')} )} {config?.onEdit && ( @@ -159,7 +165,7 @@ function HistoryDetailModal({ config }) { startDecorator={} onClick={() => config.onEdit(entry)} > - Edit Entry + {t('detail.editEntry')} )} @@ -196,7 +202,7 @@ function HistoryDetailModal({ config }) { {performer && ( } - label='Performed by' + label={t('detail.performedBy')} > } - label='Assigned to' + label={t('detail.assignedTo')} value={assignedTo.displayName} /> )} @@ -259,7 +265,7 @@ function HistoryDetailModal({ config }) { {showUpdatedAt && ( } - label='Last updated' + label={t('detail.lastUpdated')} value={fmt.dateTime(entry.updatedAt)} /> )} @@ -268,7 +274,7 @@ function HistoryDetailModal({ config }) { {entry.duration > 0 && ( } - label='Duration' + label={t('detail.duration')} value={formatDuration(entry.duration)} /> )} @@ -277,8 +283,8 @@ function HistoryDetailModal({ config }) { {entry.points > 0 && ( ★} - label='Points earned' - value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`} + label={t('detail.pointsEarned')} + value={t('detail.points', { count: entry.points })} /> )} diff --git a/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx b/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx index 431ac71..8fa8e16 100644 --- a/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx +++ b/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx @@ -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={ - {editingFilter ? 'Edit Filter' : 'New Filter'} + {editingFilter ? t('builder.editTitle') : t('builder.newTitle')} {activeConditionCount > 0 && ( {activeConditionCount} condition @@ -129,17 +131,17 @@ const AdvancedFilterBuilder = ({ {conditions.length > 0 ? ( <> - {previewCount} task{previewCount !== 1 ? 's' : ''} + {t('tasks', { count: previewCount })} {previewOverdueCount > 0 && ( - {previewOverdueCount} overdue + {t('overdue', { count: previewOverdueCount })} )} ) : ( - Add conditions to preview + {t('builder.addConditions')} )} @@ -147,7 +149,7 @@ const AdvancedFilterBuilder = ({ {/* Actions */} @@ -168,10 +170,10 @@ const AdvancedFilterBuilder = ({ level='body-xs' sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }} > - Filter Name + {t('builder.name')} { 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')} {FILTER_COLORS.map(c => ( setFilterColor(c.value)} sx={{ width: 26, diff --git a/src/views/Modals/Inputs/AttachmentViewerModal.jsx b/src/views/Modals/Inputs/AttachmentViewerModal.jsx index c0eefc2..1da2fb7 100644 --- a/src/views/Modals/Inputs/AttachmentViewerModal.jsx +++ b/src/views/Modals/Inputs/AttachmentViewerModal.jsx @@ -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={ , @@ -73,7 +75,7 @@ function AttachmentViewerModal({ config }) { )} {imgError ? ( - Failed to load image. + {t('imageLoadFailed')} ) : ( { 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 = () => ( - Create an encrypted backup of your data. This backup will include all - your chores, history, settings, and optionally your uploaded files. + {t('backup.createIntro')} @@ -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')} /> Keep this key safe—you'll need it to restore your backup @@ -228,7 +229,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) { setIncludeAssets(e.target.checked)} - label='Include uploaded files and assets' + label={t('backup.includeAssets')} /> @@ -243,8 +244,8 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) { const renderRestoreTab = () => ( - Warning: Restoring a backup will replace all your - current data. This action cannot be undone. + {t('backup.warningLabel')}{' '} + {t('backup.restoreWarning')} @@ -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')} /> @@ -293,7 +294,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) { footer={ 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 }) { - Sub Account Name * + {t('childUsers.nameLabel')} { setChildName(e.target.value) @@ -145,13 +146,13 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) { - Display Name + {t('childUsers.displayNameLabel')} { setDisplayName(e.target.value) @@ -165,7 +166,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) { - Password * + {t('childUsers.passwordLabel')} { setPassword(e.target.value) @@ -185,7 +186,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) { - Confirm Password * + {t('childUsers.confirmPasswordLabel')} { setConfirmPassword(e.target.value) diff --git a/src/views/Modals/Inputs/CreateThingModal.jsx b/src/views/Modals/Inputs/CreateThingModal.jsx index 9c2f367..ab2cb6f 100644 --- a/src/views/Modals/Inputs/CreateThingModal.jsx +++ b/src/views/Modals/Inputs/CreateThingModal.jsx @@ -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={ - Name + {t('name')}