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", "@jcesarmobile/capacitor-ocr": "^0.3.0",
"@meauxt/react-swipeable-list": "^1.0.0", "@meauxt/react-swipeable-list": "^1.0.0",
"@mui/icons-material": "^5.16.13", "@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", "@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4", "@openreplay/tracker": "^14.0.4",
"@revenuecat/purchases-capacitor": "^12.0.0", "@revenuecat/purchases-capacitor": "^12.0.0",

View File

@@ -71,7 +71,7 @@
"@jcesarmobile/capacitor-ocr": "^0.3.0", "@jcesarmobile/capacitor-ocr": "^0.3.0",
"@meauxt/react-swipeable-list": "^1.0.0", "@meauxt/react-swipeable-list": "^1.0.0",
"@mui/icons-material": "^5.16.13", "@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", "@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4", "@openreplay/tracker": "^14.0.4",
"@revenuecat/purchases-capacitor": "^12.0.0", "@revenuecat/purchases-capacitor": "^12.0.0",

View File

@@ -68,5 +68,51 @@
"results_other": "{{count}} Ergebnisse", "results_other": "{{count}} Ergebnisse",
"typeToSearch": "Zum Suchen tippen" "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", "reset": "Reset Timer",
"delete": "Delete Session" "delete": "Delete Session"
} }
},
"toolbar": {
"defaultProject": "Default Project"
},
"sort": {
"assignedToMe": "Assigned to me"
} }
} }

View File

@@ -29,7 +29,8 @@
"filters": "Filters", "filters": "Filters",
"activities": "Activities", "activities": "Activities",
"points": "Points", "points": "Points",
"settings": "Settings" "settings": "Settings",
"reportBug": "Report a Bug"
}, },
"search": { "search": {
"title": "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.", "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" "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", "crashToggle": "Crash reports",
"crashHelper": "Send us the error and what led to it when Donetick crashes no task content.", "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." "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", "later": "Maybe later",
"sentiment": { "sentiment": {
"title": "How's Donetick working for you?", "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": { "options": {
"love": "Love it", "love": "Love it",
"okay": "It's okay", "okay": "It's okay",

View File

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

View File

@@ -113,21 +113,21 @@
"cancel": "取消" "cancel": "取消"
}, },
"circleSettings": { "circleSettings": {
"title": "圈子设置", "title": "Circle settings",
"description": "当你创建或加入圈子时,账户会自动连接到该圈子。分享下方的专属圈子代码或链接即可轻松邀请好友。有人申请加入你的圈子时,下方会出现通知。", "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}} 的成员", "memberOf": "你是 {{name}} 的成员",
"yourCircleCode": "你的圈子代码是:", "yourCircleCode": "你的圈子代码是:",
"copyCode": "复制代码", "copyCode": "复制代码",
"shareInvite": "分享邀请", "shareInvite": "分享邀请",
"codeCopied": "代码已复制到剪贴板", "codeCopied": "Circle code copied!",
"linkCopied": "邀请链接已复制到剪贴板", "linkCopied": "Circle link copied!",
"myCircle": "我的圈子", "myCircle": "我的圈子",
"shareTitle": "在 Donetick 加入 {{name}}", "shareTitle": "在 Donetick 加入 {{name}}",
"shareText": "我想邀请你加入 Donetick 上的 {{name}}。", "shareText": "我想邀请你加入 Donetick 上的 {{name}}。",
"shareDialogTitle": "分享圈子邀请", "shareDialogTitle": "分享圈子邀请",
"leave": "退出圈子", "leave": "退出圈子",
"leaveConfirmTitle": "退出圈子", "leaveConfirmTitle": "退出圈子",
"leaveConfirmMessage": "确定要退出你的圈子吗?", "leaveConfirmMessage": "Are you sure you want to leave this circle?",
"leaveConfirmButton": "退出", "leaveConfirmButton": "退出",
"leftCircle": "已成功退出圈子", "leftCircle": "已成功退出圈子",
"leaveFailed": "退出圈子失败", "leaveFailed": "退出圈子失败",
@@ -161,7 +161,7 @@
"joinOtherDescription": "想加入别人的圈子?向对方索取专属圈子代码或加入链接,然后在下方输入代码即可加入。", "joinOtherDescription": "想加入别人的圈子?向对方索取专属圈子代码或加入链接,然后在下方输入代码即可加入。",
"enterCircleCode": "输入圈子代码:", "enterCircleCode": "输入圈子代码:",
"enterCodePlaceholder": "输入代码", "enterCodePlaceholder": "输入代码",
"joinCircle": "加入圈子", "joinCircle": "Join a Circle",
"joinedPending": "已成功加入圈子,请等待圈主接受你的请求。", "joinedPending": "已成功加入圈子,请等待圈主接受你的请求。",
"alreadyMember": "你已经是该圈子的成员", "alreadyMember": "你已经是该圈子的成员",
"joinFailed": "加入圈子失败" "joinFailed": "加入圈子失败"
@@ -178,7 +178,7 @@
"freeDescription": "你当前使用的是免费套餐。升级到 Plus 可解锁更多功能。", "freeDescription": "你当前使用的是免费套餐。升级到 Plus 可解锁更多功能。",
"upgrade": "升级", "upgrade": "升级",
"cancel": "取消订阅", "cancel": "取消订阅",
"password": "密码:", "password": "Password",
"changePassword": "修改密码", "changePassword": "修改密码",
"passwordChanged": "密码修改成功", "passwordChanged": "密码修改成功",
"passwordChangeFailed": "密码修改失败", "passwordChangeFailed": "密码修改失败",
@@ -390,7 +390,7 @@
"sidepanel": { "sidepanel": {
"title": "侧边栏自定义", "title": "侧边栏自定义",
"heading": "侧边栏设置", "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": "恢复默认", "resetToDefaults": "恢复默认",
"resetHelper": "这会将所有卡片的显示状态和顺序恢复为默认值。", "resetHelper": "这会将所有卡片的显示状态和顺序恢复为默认值。",
"cards": { "cards": {
@@ -421,7 +421,7 @@
} }
}, },
"theme": { "theme": {
"title": "主题偏好", "title": "Theme preferences",
"description": "选择站点的外观。可以固定使用某个主题,也可以与系统同步,在日间和夜间主题之间自动切换。", "description": "选择站点的外观。可以固定使用某个主题,也可以与系统同步,在日间和夜间主题之间自动切换。",
"themeMode": "主题模式", "themeMode": "主题模式",
"light": "浅色", "light": "浅色",

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import { Close } from '@mui/icons-material'
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy' import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { forwardRef, useId } from 'react' import { forwardRef, useId } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const WIDTH_BY_SIZE = { const WIDTH_BY_SIZE = {
sm: 400, sm: 400,
@@ -27,6 +26,7 @@ const AppModal = forwardRef(
size = 'md', size = 'md',
fullWidth = true, fullWidth = true,
isMobile: isMobileProp, isMobile: isMobileProp,
keepMounted = false,
mobilePresentation = 'sheet', mobilePresentation = 'sheet',
role = 'dialog', role = 'dialog',
showCloseButton = true, showCloseButton = true,
@@ -63,9 +63,16 @@ const AppModal = forwardRef(
onClose={handleClose} onClose={handleClose}
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={descriptionId} aria-describedby={descriptionId}
keepMounted keepMounted={keepMounted}
sx={{ 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', display: 'flex',
alignItems: isSheet ? 'flex-end' : 'center', alignItems: isSheet ? 'flex-end' : 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -85,7 +92,6 @@ const AppModal = forwardRef(
aria-describedby={descriptionId} aria-describedby={descriptionId}
variant='outlined' variant='outlined'
sx={{ sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
width: isFullscreen width: isFullscreen

View File

@@ -1,6 +1,8 @@
import { Add, Close } from '@mui/icons-material' import { Add, Close } from '@mui/icons-material'
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy' import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
const ActiveFilterChips = ({ const ActiveFilterChips = ({
chipSize = 'md', chipSize = 'md',
chipSx, chipSx,
@@ -18,6 +20,7 @@ const ActiveFilterChips = ({
showAddChip = false, showAddChip = false,
totalCount, totalCount,
}) => { }) => {
const { t } = useTranslation('common')
if (!chips.length) { if (!chips.length) {
return null return null
} }
@@ -161,7 +164,7 @@ const ActiveFilterChips = ({
...clearButtonSx, ...clearButtonSx,
}} }}
> >
Clear all {t('clearAll')}
</Button> </Button>
)} )}
</Box> </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 { useState } from 'react'
import { useTranslation } from 'react-i18next'
const useConfirmationModal = () => { const useConfirmationModal = () => {
const { t } = useTranslation('common')
const [confirmModalConfig, setConfirmModalConfig] = useState({}) const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = ( const showConfirmation = (
message, message,
title, title,
onConfirm, onConfirm,
confirmText = 'Confirm', confirmText = t('confirm'),
cancelText = 'Cancel', cancelText = t('cancel'),
color = 'primary', color = 'primary',
) => { ) => {
setConfirmModalConfig({ setConfirmModalConfig({

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
const stripHtml = value => { const stripHtml = value => {
if (!value) return '' if (!value) return ''
if (typeof globalThis.document === 'undefined') if (typeof globalThis.document === 'undefined')
@@ -17,26 +19,6 @@ const HISTORY_STATUS = {
6: 'rescheduled', 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 = [] const providers = []
export const registerSearchProvider = provider => { export const registerSearchProvider = provider => {
@@ -166,15 +148,15 @@ registerSearchProvider({
registerSearchProvider({ registerSearchProvider({
id: 'settings', id: 'settings',
getDocuments: ({ isParent }) => getDocuments: ({ isParent, t }) =>
SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map( SETTINGS_SECTIONS.filter(({ parentOnly }) => !parentOnly || isParent).map(
([id, title, description]) => ({ id }) =>
document('settings', { document('settings', {
id: `setting:${id}`, id: `setting:${id}`,
entityId: id, entityId: id,
title, title: t(`overview.sections.${id}.title`),
subtitle: 'Settings', subtitle: 'Settings',
body: description, body: t(`overview.sections.${id}.description`),
keywords: `preferences configuration ${id}`, keywords: `preferences configuration ${id}`,
route: `/settings/${id}`, route: `/settings/${id}`,
}), }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import ProjectModal from '../Modals/Inputs/ProjectModal' import ProjectModal from '../Modals/Inputs/ProjectModal'
@@ -40,6 +41,7 @@ const ProjectCardContent = ({
onCardClick, onCardClick,
onToggleActions, onToggleActions,
}) => { }) => {
const { t } = useTranslation('projects')
// Check if current user owns this project // Check if current user owns this project
const isOwnedByCurrentUser = project.created_by === currentUserId const isOwnedByCurrentUser = project.created_by === currentUserId
const isDefaultProject = project.id === 'default' const isDefaultProject = project.id === 'default'
@@ -144,7 +146,7 @@ const ProjectCardContent = ({
fontWeight: 'md', fontWeight: 'md',
}} }}
> >
Default {t('defaultChip')}
</Chip> </Chip>
)} )}
</Typography> </Typography>
@@ -178,7 +180,7 @@ const ProjectCardContent = ({
color: 'primary.500', color: 'primary.500',
}} }}
> >
{taskCount} tasks {t('tasks', { count: taskCount })}
</Chip> </Chip>
{!isOwnedByCurrentUser && !isDefaultProject && ( {!isOwnedByCurrentUser && !isDefaultProject && (
@@ -193,7 +195,7 @@ const ProjectCardContent = ({
fontWeight: 'md', fontWeight: 'md',
}} }}
> >
Shared {t('shared')}
</Chip> </Chip>
)} )}
</Box> </Box>
@@ -218,6 +220,7 @@ const ProjectCardContent = ({
} }
const ProjectView = () => { const ProjectView = () => {
const { t } = useTranslation('projects')
const { data: projects, isProjectsLoading, isError } = useProjects() const { data: projects, isProjectsLoading, isError } = useProjects()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
@@ -250,11 +253,11 @@ const ProjectView = () => {
const project = userProjects.find(p => p.id === id) const project = userProjects.find(p => p.id === id)
setConfirmationModel({ setConfirmationModel({
isOpen: true, isOpen: true,
title: 'Delete Project', title: t('delete.title'),
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).`, message: t('delete.message', { name: project?.name }),
confirmText: 'Delete', confirmText: t('common:delete'),
color: 'danger', color: 'danger',
cancelText: 'Cancel', cancelText: t('common:cancel'),
onClose: confirmed => { onClose: confirmed => {
if (confirmed === true) { if (confirmed === true) {
handleDeleteProject(id) handleDeleteProject(id)
@@ -347,7 +350,7 @@ const ProjectView = () => {
if (isError) { if (isError) {
return ( return (
<Typography color='danger' textAlign='center'> <Typography color='danger' textAlign='center'>
Failed to load projects. Please try again. {t('loadError')}
</Typography> </Typography>
) )
} }
@@ -360,11 +363,10 @@ const ProjectView = () => {
level='h3' level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }} sx={{ fontWeight: 'lg', color: 'text.primary' }}
> >
Projects {t('common:navigation.projects')}
</Typography> </Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}> <Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Organize your tasks into projects. Create custom workspaces to keep {t('blurb')}
your tasks organized and easily accessible.
</Typography> </Typography>
</Stack> </Stack>
</Box> </Box>
@@ -378,8 +380,8 @@ const ProjectView = () => {
<ProjectCardContent <ProjectCardContent
project={{ project={{
id: 'default', id: 'default',
name: 'Default Project', name: t('chores:toolbar.defaultProject'),
description: 'All tasks without a specific project', description: t('defaultDescription'),
icon: 'FolderOpen', icon: 'FolderOpen',
color: '#1976d2', color: '#1976d2',
created_by: userProfile?.id, created_by: userProfile?.id,
@@ -389,7 +391,7 @@ const ProjectView = () => {
onCardClick={() => onCardClick={() =>
handleCardClick({ handleCardClick({
id: 'default', id: 'default',
name: 'Default Project', name: t('chores:toolbar.defaultProject'),
icon: 'FolderOpen', icon: 'FolderOpen',
color: '#1976d2', color: '#1976d2',
}) })
@@ -429,7 +431,7 @@ const ProjectView = () => {
> >
<EditIcon sx={{ fontSize: 20 }} /> <EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}> <Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit {t('common:edit')}
</Typography> </Typography>
</Box> </Box>
</SwipeAction> </SwipeAction>
@@ -450,7 +452,7 @@ const ProjectView = () => {
> >
<DeleteIcon sx={{ fontSize: 20 }} /> <DeleteIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}> <Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete {t('common:delete')}
</Typography> </Typography>
</Box> </Box>
</SwipeAction> </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 { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { networkManager } from '../../hooks/NetworkManager' import { networkManager } from '../../hooks/NetworkManager'
import useConfirmationModal from '../../hooks/useConfirmationModal' import useConfirmationModal from '../../hooks/useConfirmationModal'
@@ -29,6 +30,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import PolicyUpdateModal from '../Modals/PolicyUpdateModal' import PolicyUpdateModal from '../Modals/PolicyUpdateModal'
const DeveloperSettings = () => { const DeveloperSettings = () => {
const { t } = useTranslation('settings')
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { confirmModalConfig, showConfirmation } = useConfirmationModal() const { confirmModalConfig, showConfirmation } = useConfirmationModal()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
@@ -240,7 +242,7 @@ const DeveloperSettings = () => {
if (result.success) { if (result.success) {
showNotification({ showNotification({
type: 'success', type: 'success',
message: 'Token refreshed successfully', message: t('developer.tokenRefreshed'),
}) })
// Reload token expiry data // Reload token expiry data
@@ -276,7 +278,7 @@ const DeveloperSettings = () => {
const data = await response.json() const data = await response.json()
showNotification({ showNotification({
type: 'success', type: 'success',
message: 'Refresh token endpoint called successfully', message: t('developer.endpointCalled'),
}) })
// Reload token expiry data // Reload token expiry data
@@ -345,7 +347,7 @@ const DeveloperSettings = () => {
showNotification({ showNotification({
type: 'success', type: 'success',
message: 'Local offline database cleared. Starting full sync...', message: t('developer.clearDbCleared'),
}) })
const didSync = await syncEngine.sync() const didSync = await syncEngine.sync()
@@ -353,7 +355,7 @@ const DeveloperSettings = () => {
await queryClient.invalidateQueries() await queryClient.invalidateQueries()
showNotification({ showNotification({
type: 'success', type: 'success',
message: 'Full sync completed from the beginning', message: t('developer.clearDbDone'),
}) })
} else { } else {
showNotification({ showNotification({
@@ -450,7 +452,7 @@ const DeveloperSettings = () => {
<Typography level='h3'>Developer Settings</Typography> <Typography level='h3'>Developer Settings</Typography>
<Divider /> <Divider />
<Typography level='body-md'> <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 state. This information is useful for debugging and development
purposes. purposes.
</Typography> </Typography>
@@ -548,7 +550,7 @@ const DeveloperSettings = () => {
</> </>
) : ( ) : (
<Typography level='body-sm' color='neutral'> <Typography level='body-sm' color='neutral'>
Refresh tokens are managed via HTTP-only cookies on web platform {t('developer.refreshCookieNote')}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -1152,7 +1154,7 @@ const DeveloperSettings = () => {
</Box> </Box>
) : ( ) : (
<Typography level='body-sm' color='neutral'> <Typography level='body-sm' color='neutral'>
No reconnection information available {t('developer.noReconnectInfo')}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -1192,7 +1194,7 @@ const DeveloperSettings = () => {
</Box> </Box>
) : ( ) : (
<Typography level='body-sm' color='neutral'> <Typography level='body-sm' color='neutral'>
No timeout information available {t('developer.noTimeoutInfo')}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -1237,7 +1239,7 @@ const DeveloperSettings = () => {
</Box> </Box>
) : ( ) : (
<Typography level='body-sm' color='neutral'> <Typography level='body-sm' color='neutral'>
No debug information available {t('developer.noDebugInfo')}
</Typography> </Typography>
)} )}
</Box> </Box>

View File

@@ -1,23 +1,4 @@
import { import { BugReport, ChevronRight, Feedback, Star } from '@mui/icons-material'
AccountCircle,
Api,
BugReport,
ChevronRight,
Circle,
Code,
FamilyRestroom,
Feedback,
Language,
Notifications,
Palette,
Person,
PrivacyTip,
Security,
Settings,
Star,
Storage,
ViewSidebar,
} from '@mui/icons-material'
import { import {
Avatar, Avatar,
Box, Box,
@@ -38,6 +19,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { SETTINGS_SECTIONS } from '../../constants/settingsSections'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers' import { isPlusAccount } from '../../utils/Helpers'
import { isParentUser } from '../../utils/UserHelpers' import { isParentUser } from '../../utils/UserHelpers'
@@ -52,91 +34,13 @@ const SettingsOverview = () => {
const [bugReportOpen, setBugReportOpen] = useState(false) const [bugReportOpen, setBugReportOpen] = useState(false)
const settingsCards = [ const settingsCards = [
{ ...SETTINGS_SECTIONS.map(({ icon: Icon, id, isBeta }) => ({
id: 'profile', id,
title: t('overview.sections.profile.title'), title: t(`overview.sections.${id}.title`),
description: t('overview.sections.profile.description'), description: t(`overview.sections.${id}.description`),
icon: <Person />, icon: <Icon />,
}, isBeta,
{ })),
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 />,
},
{ {
id: 'feedback', id: 'feedback',
title: t('overview.sections.feedback.title'), title: t('overview.sections.feedback.title'),
@@ -161,23 +65,19 @@ const SettingsOverview = () => {
navigate(`/settings/${setting.id}`) navigate(`/settings/${setting.id}`)
} }
const parentOnlyIds = SETTINGS_SECTIONS.filter(
section => section.parentOnly,
).map(section => section.id)
// Filter settings based on user type // Filter settings based on user type
const getAvailableSettings = () => { const getAvailableSettings = () => {
const parentOnlySettings = [
'children',
'mfa',
'apitokens',
'circle',
'account',
]
if (isParentUser(userProfile)) { if (isParentUser(userProfile)) {
// Parent users can access all settings // Parent users can access all settings
return settingsCards return settingsCards
} else { } else {
// Child users can only access basic settings // Child users can only access basic settings
return settingsCards.filter( 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 { Card, IconButton, Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
const SummaryCard = () => { const SummaryCard = () => {
const { t } = useTranslation('common')
return ( return (
<Card> <Card>
<div className='flex justify-between'> <div className='flex justify-between'>
<div> <div>
<Typography level='h2'>Summary</Typography> <Typography level='h2'>Summary</Typography>
<Typography level='body-xs'> <Typography level='body-xs'>
This is a summary of your chores {t('summaryOfChores')}
</Typography> </Typography>
</div> </div>
<IconButton> <IconButton>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,14 @@
import { Add } from '@mui/icons-material' import { Add, KeyboardArrowDown } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy' import {
Box,
Button,
Dropdown,
ListItemDecorator,
Menu,
MenuButton,
MenuItem,
Typography,
} from '@mui/joy'
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
@@ -20,6 +29,7 @@ import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import { CreateLabel } from '../../utils/Fetcher' import { CreateLabel } from '../../utils/Fetcher'
import { imageSourceToFile } from '../../utils/FileConvert' import { imageSourceToFile } from '../../utils/FileConvert'
import { isPlusAccount } from '../../utils/Helpers' import { isPlusAccount } from '../../utils/Helpers'
import { getIconComponent } from '../../utils/ProjectIcons'
import { generateUUID } from '../../utils/UUID' import { generateUUID } from '../../utils/UUID'
import { useLabels } from '../Labels/LabelQueries' import { useLabels } from '../Labels/LabelQueries'
import { useProjects } from '../Projects/ProjectQueries' import { useProjects } from '../Projects/ProjectQueries'
@@ -122,6 +132,13 @@ const getInitialProject = () => {
return 'default' return 'default'
} }
const DEFAULT_PROJECT = {
id: 'default',
name: 'Default Project',
color: '#9CA3AF',
icon: 'FolderOpen',
}
const PRIORITY_COLORS = { const PRIORITY_COLORS = {
0: TASK_COLOR.NO_PRIORITY, 0: TASK_COLOR.NO_PRIORITY,
1: TASK_COLOR.PRIORITY_1, 1: TASK_COLOR.PRIORITY_1,
@@ -175,7 +192,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: circleMembers, isLoading: isCircleMembersLoading } = const { data: circleMembers, isLoading: isCircleMembersLoading } =
useCircleMembers() useCircleMembers()
const { isLoading: isProjectsLoading } = useProjects() const { data: projects, isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore() const createChoreMutation = useCreateChore()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -298,6 +315,17 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const [useCustomTime, setUseCustomTime] = useState(false) const [useCustomTime, setUseCustomTime] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject) 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 [attachments, setAttachments] = useState([])
const [draftId, setDraftId] = useState(() => generateUUID()) const [draftId, setDraftId] = useState(() => generateUUID())
@@ -1131,6 +1159,50 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
title='Create new task' title='Create new task'
footer={ footer={
<ModalActions> <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 <Button
variant='outlined' variant='outlined'
color='neutral' color='neutral'

View File

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

View File

@@ -1,7 +1,9 @@
import { Chip, List, ListItem, ListItemButton, Textarea } from '@mui/joy' import { Chip, List, ListItem, ListItemButton, Textarea } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => { const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
const { t } = useTranslation('common')
const [filteredOptions, setFilteredOptions] = useState([]) const [filteredOptions, setFilteredOptions] = useState([])
const [menuVisible, setMenuVisible] = useState(false) const [menuVisible, setMenuVisible] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1) const [highlightedIndex, setHighlightedIndex] = useState(-1)
@@ -82,7 +84,7 @@ const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
value={value} value={value}
onChange={onChange} onChange={onChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder='Type here...' placeholder={t('autocompletePlaceholder')}
/> />
{menuVisible && ( {menuVisible && (
<List ref={menuRef} style={{ position: 'absolute', zIndex: 1000 }}> <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 { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material' import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BaseOptionPicker = ({ const BaseOptionPicker = ({
items = [], 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)}> <ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet <Sheet

View File

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

View File

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

View File

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

View File

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

View File

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