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:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -39,7 +39,7 @@
|
||||
"@jcesarmobile/capacitor-ocr": "^0.3.0",
|
||||
"@meauxt/react-swipeable-list": "^1.0.0",
|
||||
"@mui/icons-material": "^5.16.13",
|
||||
"@mui/joy": "^5.0.0-beta.20",
|
||||
"@mui/joy": "5.0.0-beta.52",
|
||||
"@mui/material": "^5.15.2",
|
||||
"@openreplay/tracker": "^14.0.4",
|
||||
"@revenuecat/purchases-capacitor": "^12.0.0",
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"@jcesarmobile/capacitor-ocr": "^0.3.0",
|
||||
"@meauxt/react-swipeable-list": "^1.0.0",
|
||||
"@mui/icons-material": "^5.16.13",
|
||||
"@mui/joy": "^5.0.0-beta.20",
|
||||
"@mui/joy": "5.0.0-beta.52",
|
||||
"@mui/material": "^5.15.2",
|
||||
"@openreplay/tracker": "^14.0.4",
|
||||
"@revenuecat/purchases-capacitor": "^12.0.0",
|
||||
|
||||
@@ -68,5 +68,51 @@
|
||||
"results_other": "{{count}} Ergebnisse",
|
||||
"typeToSearch": "Zum Suchen tippen"
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Vielleicht später",
|
||||
"sentiment": {
|
||||
"title": "Was hältst du von Donetick?",
|
||||
"subtitle": "Ihre Antwort hilft uns zu entscheiden, was wir als Nächstes entwickeln sollen.",
|
||||
"options": {
|
||||
"love": "Ich liebe es",
|
||||
"okay": "Es ist in Ordnung",
|
||||
"issues": "Ich habe Probleme"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Fehler",
|
||||
"missingFeature": "Fehlende Funktion",
|
||||
"tooComplicated": "Zu kompliziert",
|
||||
"slow": "Langsam",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"ai": "KI",
|
||||
"other": "Sonstiges"
|
||||
},
|
||||
"details": {
|
||||
"title": "Was könnten wir verbessern?",
|
||||
"messageLabel": "Weiteres Feedback",
|
||||
"messagePlaceholder": "Was ist passiert oder was können wir verbessern?",
|
||||
"contextNote": "Wir werden folgende Diagnosedaten einschließen, um Probleme reproduzieren zu können: App-Version, Gerätebeschreibung und Betriebssystem.",
|
||||
"submit": "Feedback senden",
|
||||
"contextNoteSelfHosted": "Du befindest dich auf einem selbst-gehosteten Instanz, daher wird keine Meldung von deinem Server erstellt. Wir eröffnen ein Github-Problem welches vorausgefüllt ist. Du selbst kannst es prüfen und bearbeiten bevor es erstellt wird.",
|
||||
"submitSelfHosted": "Weiter zu GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Schön, dass es Ihnen gefällt!",
|
||||
"subtitle": "Eine Bewertung oder ein Stern hilft anderen Menschen Donetich zu finden.",
|
||||
"github": "Als GitHub Favorit setzen",
|
||||
"appStore": "Im App Store bewerten",
|
||||
"playStore": "Auf Google Play bewerten"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Vielen Dank für Ihr Feedback!",
|
||||
"subtitle": "Wir lesen jede Antwort und diese entscheiden was wir als Nächstes tun."
|
||||
},
|
||||
"github": {
|
||||
"title": "Melde es auf GitHub,",
|
||||
"subtitle": "Wir haben ein Problem mit Ihren Notizen und Versionsdetails vorausgefüllt. Es wurde noch nichts verschickt – überprüfe es und poste, sobald du bereit bist.",
|
||||
"open": "Eröffne das Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
111
public/locales/en/auth.json
Normal file
111
public/locales/en/auth.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@@ -71,5 +71,11 @@
|
||||
"reset": "Reset Timer",
|
||||
"delete": "Delete Session"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"defaultProject": "Default Project"
|
||||
},
|
||||
"sort": {
|
||||
"assignedToMe": "Assigned to me"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"filters": "Filters",
|
||||
"activities": "Activities",
|
||||
"points": "Points",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"reportBug": "Report a Bug"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
@@ -121,5 +122,37 @@
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
},
|
||||
"done": "Done",
|
||||
"clearAll": "Clear all",
|
||||
"upload": {
|
||||
"quotaTitle": "Storage Quota Exceeded",
|
||||
"quotaMessage": "You have exceeded your quota for uploading files.",
|
||||
"tooLargeTitle": "File Too Large",
|
||||
"tooLargeMessage": "The file you are trying to upload is too large.",
|
||||
"deniedTitle": "Permission Denied",
|
||||
"deniedMessage": "You do not have permission to upload files."
|
||||
},
|
||||
"getStarted": "Get Started!",
|
||||
"imageLoadFailed": "Failed to load image.",
|
||||
"typeHere": "Type in here…",
|
||||
"summaryOfChores": "This is a summary of your chores",
|
||||
"loadingOffline": "You are offline",
|
||||
"loadingOfflineSub": "This not available while offline. Please check your internet connection and try again.",
|
||||
"loadingSlow": "This is taking longer than usual. There might be an issue.",
|
||||
"navigateBack": "Navigate Back",
|
||||
"bottomNav": "Bottom Navigation",
|
||||
"profile": "Profile",
|
||||
"autocompletePlaceholder": "Type here...",
|
||||
"errorScreen": {
|
||||
"title": "Something went wrong",
|
||||
"fallback": "An unexpected error occurred. Try reloading — it usually fixes it.",
|
||||
"tryAgain": "Try again",
|
||||
"home": "Home",
|
||||
"login": "Login",
|
||||
"hideDetails": "Hide error details",
|
||||
"showDetails": "Show error details",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copied": "Error details copied to clipboard"
|
||||
}
|
||||
}
|
||||
|
||||
17
public/locales/en/filters.json
Normal file
17
public/locales/en/filters.json
Normal 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"
|
||||
}
|
||||
89
public/locales/en/history.json
Normal file
89
public/locales/en/history.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
public/locales/en/labels.json
Normal file
9
public/locales/en/labels.json
Normal 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."
|
||||
}
|
||||
74
public/locales/en/points.json
Normal file
74
public/locales/en/points.json
Normal 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"
|
||||
}
|
||||
}
|
||||
24
public/locales/en/projects.json
Normal file
24
public/locales/en/projects.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -503,5 +503,78 @@
|
||||
"crashToggle": "Crash reports",
|
||||
"crashHelper": "Send us the error and what led to it when Donetick crashes no task content.",
|
||||
"footnote": "You can change these choices at any time."
|
||||
},
|
||||
"subscription": {
|
||||
"sessionFailed": "Failed to create subscription session",
|
||||
"errorTitle": "Subscription Error",
|
||||
"errorMessage": "Failed to start subscription process. Please try again.",
|
||||
"subscribe": "Subscribe",
|
||||
"cancelAnytime": "Cancel anytime. No hidden fees. Secure payment powered by Stripe."
|
||||
},
|
||||
"childUsers": {
|
||||
"errNameRequired": "Sub account name is required",
|
||||
"errNameMin": "Sub account name must be at least 2 characters",
|
||||
"errNameMax": "Sub account name must be less than 20 characters",
|
||||
"errNameChars": "Sub account name can only contain lowercase letters, dot and dash",
|
||||
"errPasswordRequired": "Password is required",
|
||||
"errPasswordLength": "Password must be between 8 and 64 characters",
|
||||
"errPasswordMatch": "Passwords do not match",
|
||||
"errDisplayNameMax": "Display name must be less than 50 characters",
|
||||
"createTitle": "Create Sub Account",
|
||||
"createButton": "Create Account",
|
||||
"nameLabel": "Sub Account Name *",
|
||||
"namePlaceholder": "Enter sub account name (e.g., sarah)",
|
||||
"displayNameLabel": "Display Name",
|
||||
"displayNamePlaceholder": "Display name (optional, defaults to sub account name)",
|
||||
"passwordLabel": "Password *",
|
||||
"passwordPlaceholder": "Enter password (8-64 characters)",
|
||||
"confirmPasswordLabel": "Confirm Password *",
|
||||
"confirmPasswordPlaceholder": "Confirm password"
|
||||
},
|
||||
"passwordChange": {
|
||||
"minLength": "Password must be at least 8 characters",
|
||||
"maxLength": "Password must be less than 64 characters"
|
||||
},
|
||||
"payment": {
|
||||
"cancelledTitle": "Payment has been cancelled",
|
||||
"cancelledRedirect": "You will be redirected to the main page shortly.",
|
||||
"successTitle": "Payment Successful!",
|
||||
"successRedirect": "You will be redirected to the settings page shortly."
|
||||
},
|
||||
"developer": {
|
||||
"tokenRefreshed": "Token refreshed successfully",
|
||||
"endpointCalled": "Refresh token endpoint called successfully",
|
||||
"clearDbCleared": "Local offline database cleared. Starting full sync...",
|
||||
"clearDbDone": "Full sync completed from the beginning",
|
||||
"intro": "View technical information about your authentication tokens and session",
|
||||
"refreshCookieNote": "Refresh tokens are managed via HTTP-only cookies on web platform",
|
||||
"noReconnectInfo": "No reconnection information available",
|
||||
"noTimeoutInfo": "No timeout information available",
|
||||
"noDebugInfo": "No debug information available"
|
||||
},
|
||||
"backup": {
|
||||
"keyRequired": "Encryption key is required",
|
||||
"created": "Backup created and downloaded successfully",
|
||||
"createFailed": "Failed to create backup",
|
||||
"selectFile": "Please select a backup file",
|
||||
"restored": "Backup restored successfully. Please refresh the page.",
|
||||
"restoreFailed": "Failed to restore backup",
|
||||
"readFailed": "Failed to read backup file",
|
||||
"createIntro": "Create an encrypted backup of your data. This backup will include all your chores, history, settings, and optionally your uploaded files.",
|
||||
"keyPlaceholder": "Enter a strong encryption key",
|
||||
"includeAssets": "Include uploaded files and assets",
|
||||
"warningLabel": "Warning:",
|
||||
"restoreWarning": "Restoring a backup will replace all your current data. This action cannot be undone.",
|
||||
"restoreKeyPlaceholder": "Enter the encryption key used for this backup"
|
||||
},
|
||||
"deletion": {
|
||||
"passwordRequired": "Please enter your password to continue",
|
||||
"passwordAndDelete": "Please enter your password and type DELETE to confirm",
|
||||
"failed": "Failed to delete account",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"transferIntro": "You own circles that require ownership transfer before deletion. Please select new owners:",
|
||||
"selectOwner": "Select new owner",
|
||||
"confirmPrompt": "Please enter your password and type DELETE to confirm",
|
||||
"typeDelete": "DELETE"
|
||||
}
|
||||
}
|
||||
|
||||
18
public/locales/en/things.json
Normal file
18
public/locales/en/things.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
public/locales/en/timer.json
Normal file
29
public/locales/en/timer.json
Normal 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."
|
||||
}
|
||||
@@ -73,7 +73,7 @@
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"subtitle": "Votre réponse nous aide à décider de ce qu'il faut construire ensuite.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"footer": {
|
||||
"navigate": "导航",
|
||||
"open": "打开",
|
||||
"results_one": "{{count}} result",
|
||||
"results_other": "{{count}} 个结果",
|
||||
"typeToSearch": "输入以搜索"
|
||||
}
|
||||
|
||||
@@ -113,21 +113,21 @@
|
||||
"cancel": "取消"
|
||||
},
|
||||
"circleSettings": {
|
||||
"title": "圈子设置",
|
||||
"description": "当你创建或加入圈子时,账户会自动连接到该圈子。分享下方的专属圈子代码或链接即可轻松邀请好友。有人申请加入你的圈子时,下方会出现通知。",
|
||||
"title": "Circle settings",
|
||||
"description": "Your account is automatically connected to a Circle when you create or join one. Easily invite friends by sharing the unique Circle code or link below. You'll receive a notification below when someone requests to join your Circle. If you'd like to leave, simply hit the 'Leave Circle' button.",
|
||||
"memberOf": "你是 {{name}} 的成员",
|
||||
"yourCircleCode": "你的圈子代码是:",
|
||||
"copyCode": "复制代码",
|
||||
"shareInvite": "分享邀请",
|
||||
"codeCopied": "代码已复制到剪贴板",
|
||||
"linkCopied": "邀请链接已复制到剪贴板",
|
||||
"codeCopied": "Circle code copied!",
|
||||
"linkCopied": "Circle link copied!",
|
||||
"myCircle": "我的圈子",
|
||||
"shareTitle": "在 Donetick 加入 {{name}}",
|
||||
"shareText": "我想邀请你加入 Donetick 上的 {{name}}。",
|
||||
"shareDialogTitle": "分享圈子邀请",
|
||||
"leave": "退出圈子",
|
||||
"leaveConfirmTitle": "退出圈子",
|
||||
"leaveConfirmMessage": "确定要退出你的圈子吗?",
|
||||
"leaveConfirmMessage": "Are you sure you want to leave this circle?",
|
||||
"leaveConfirmButton": "退出",
|
||||
"leftCircle": "已成功退出圈子",
|
||||
"leaveFailed": "退出圈子失败",
|
||||
@@ -161,7 +161,7 @@
|
||||
"joinOtherDescription": "想加入别人的圈子?向对方索取专属圈子代码或加入链接,然后在下方输入代码即可加入。",
|
||||
"enterCircleCode": "输入圈子代码:",
|
||||
"enterCodePlaceholder": "输入代码",
|
||||
"joinCircle": "加入圈子",
|
||||
"joinCircle": "Join a Circle",
|
||||
"joinedPending": "已成功加入圈子,请等待圈主接受你的请求。",
|
||||
"alreadyMember": "你已经是该圈子的成员",
|
||||
"joinFailed": "加入圈子失败"
|
||||
@@ -178,7 +178,7 @@
|
||||
"freeDescription": "你当前使用的是免费套餐。升级到 Plus 可解锁更多功能。",
|
||||
"upgrade": "升级",
|
||||
"cancel": "取消订阅",
|
||||
"password": "密码:",
|
||||
"password": "Password",
|
||||
"changePassword": "修改密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeFailed": "密码修改失败",
|
||||
@@ -390,7 +390,7 @@
|
||||
"sidepanel": {
|
||||
"title": "侧边栏自定义",
|
||||
"heading": "侧边栏设置",
|
||||
"description": "设置侧边栏显示哪些卡片以及它们的顺序。拖放可重新排序,也可切换每张卡片的显示状态。",
|
||||
"description": "Customize the layout and visibility of cards in the sidepanel. This section is only available on large screen devices such as tablets and desktops.",
|
||||
"resetToDefaults": "恢复默认",
|
||||
"resetHelper": "这会将所有卡片的显示状态和顺序恢复为默认值。",
|
||||
"cards": {
|
||||
@@ -421,7 +421,7 @@
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题偏好",
|
||||
"title": "Theme preferences",
|
||||
"description": "选择站点的外观。可以固定使用某个主题,也可以与系统同步,在日间和夜间主题之间自动切换。",
|
||||
"themeMode": "主题模式",
|
||||
"light": "浅色",
|
||||
|
||||
@@ -5,8 +5,10 @@ import AppModal from './common/AppModal'
|
||||
import ModalActions from './common/ModalActions'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SubscriptionModal = ({ open, onClose }) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const [selectedPlan, setSelectedPlan] = useState('yearly')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { showError } = useNotification()
|
||||
@@ -45,7 +47,7 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
const response = await GetSubscriptionSession()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create subscription session')
|
||||
throw new Error(t('subscription.sessionFailed'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -59,8 +61,8 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
} catch (error) {
|
||||
console.error('Subscription error:', error)
|
||||
showError({
|
||||
title: 'Subscription Error',
|
||||
message: 'Failed to start subscription process. Please try again.',
|
||||
title: t('subscription.errorTitle'),
|
||||
message: t('subscription.errorMessage'),
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -71,7 +73,7 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
<AppModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title='Upgrade to Plus'
|
||||
title={t('overview.upgrade.title')}
|
||||
description='Unlock reminders, rich task details, and advanced automation.'
|
||||
size='lg'
|
||||
closeOnBackdrop={!isLoading}
|
||||
@@ -79,9 +81,9 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
footer={
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
|
||||
secondary={{ label: t('accountSettings.cancel'), onClick: onClose, disabled: isLoading }}
|
||||
primary={{
|
||||
label: 'Subscribe',
|
||||
label: t('subscription.subscribe'),
|
||||
onClick: handleSubscribe,
|
||||
loading: isLoading,
|
||||
}}
|
||||
@@ -217,7 +219,7 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 3 }}
|
||||
>
|
||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||
{t('subscription.cancelAnytime')}
|
||||
</Typography>
|
||||
</AppModal>
|
||||
)
|
||||
|
||||
@@ -63,11 +63,14 @@ const LogoContainer = styled(Box)({
|
||||
marginBottom: '24px',
|
||||
})
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LoadingScreen = ({
|
||||
message = 'Loading...',
|
||||
message = null,
|
||||
showLogo = true,
|
||||
size = 'lg',
|
||||
}) => {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<LoadingContent>
|
||||
@@ -82,7 +85,7 @@ const LoadingScreen = ({
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Done
|
||||
{t('done')}
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
</LogoContainer>
|
||||
@@ -96,7 +99,7 @@ const LoadingScreen = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<PulsingText level='body-md'>{message}</PulsingText>
|
||||
<PulsingText level='body-md'>{message ?? t('loading')}</PulsingText>
|
||||
</LoadingContent>
|
||||
</LoadingContainer>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Close } from '@mui/icons-material'
|
||||
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { forwardRef, useId } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const WIDTH_BY_SIZE = {
|
||||
sm: 400,
|
||||
@@ -27,6 +26,7 @@ const AppModal = forwardRef(
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
isMobile: isMobileProp,
|
||||
keepMounted = false,
|
||||
mobilePresentation = 'sheet',
|
||||
role = 'dialog',
|
||||
showCloseButton = true,
|
||||
@@ -63,9 +63,16 @@ const AppModal = forwardRef(
|
||||
onClose={handleClose}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
keepMounted
|
||||
keepMounted={keepMounted}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_BACKDROP,
|
||||
// Joy raises portaled listboxes above modals, but its selector misses
|
||||
// Menu because Menu renders with role="menu". Keep the workaround in
|
||||
// the modal primitive so individual menus never need a z-index.
|
||||
...(open && {
|
||||
'& ~ [role="menu"]': {
|
||||
'--unstable_popup-zIndex': 'calc(var(--joy-zIndex-modal) + 1)',
|
||||
},
|
||||
}),
|
||||
display: 'flex',
|
||||
alignItems: isSheet ? 'flex-end' : 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -85,7 +92,6 @@ const AppModal = forwardRef(
|
||||
aria-describedby={descriptionId}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: isFullscreen
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Add, Close } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ActiveFilterChips = ({
|
||||
chipSize = 'md',
|
||||
chipSx,
|
||||
@@ -18,6 +20,7 @@ const ActiveFilterChips = ({
|
||||
showAddChip = false,
|
||||
totalCount,
|
||||
}) => {
|
||||
const { t } = useTranslation('common')
|
||||
if (!chips.length) {
|
||||
return null
|
||||
}
|
||||
@@ -161,7 +164,7 @@ const ActiveFilterChips = ({
|
||||
...clearButtonSx,
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
36
src/constants/settingsSections.js
Normal file
36
src/constants/settingsSections.js
Normal 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 },
|
||||
]
|
||||
@@ -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
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const useConfirmationModal = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
|
||||
const showConfirmation = (
|
||||
message,
|
||||
title,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText = t('confirm'),
|
||||
cancelText = t('cancel'),
|
||||
color = 'primary',
|
||||
) => {
|
||||
setConfirmModalConfig({
|
||||
|
||||
@@ -5,12 +5,14 @@ import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const useFileUpload = ({
|
||||
draftId,
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
} = {}) => {
|
||||
const { t } = useTranslation('common')
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
@@ -58,14 +60,14 @@ export const useFileUpload = ({
|
||||
|
||||
if (response.status === 507) {
|
||||
showError({
|
||||
title: 'Storage Quota Exceeded',
|
||||
message: 'You have exceeded your quota for uploading files.',
|
||||
title: t('upload.quotaTitle'),
|
||||
message: t('upload.quotaMessage'),
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 413) {
|
||||
showError({
|
||||
title: 'File Too Large',
|
||||
message: 'The file you are trying to upload is too large.',
|
||||
title: t('upload.tooLargeTitle'),
|
||||
message: t('upload.tooLargeMessage'),
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
@@ -76,8 +78,8 @@ export const useFileUpload = ({
|
||||
return null
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Permission Denied',
|
||||
message: 'You do not have permission to upload files.',
|
||||
title: t('upload.deniedTitle'),
|
||||
message: t('upload.deniedMessage'),
|
||||
})
|
||||
return null
|
||||
} else if (!response.ok) {
|
||||
@@ -105,7 +107,7 @@ export const useFileUpload = ({
|
||||
return null
|
||||
}
|
||||
},
|
||||
[entityType, entityId, draftId, showError, userProfile],
|
||||
[entityType, entityId, draftId, showError, userProfile, t],
|
||||
)
|
||||
|
||||
return { uploadFile, isPlus: isPlusAccount(userProfile) }
|
||||
|
||||
@@ -22,7 +22,19 @@ i18n
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
},
|
||||
|
||||
ns: ['common', 'settings', 'chores'],
|
||||
ns: [
|
||||
'auth',
|
||||
'common',
|
||||
'settings',
|
||||
'chores',
|
||||
'labels',
|
||||
'history',
|
||||
'filters',
|
||||
'things',
|
||||
'projects',
|
||||
'timer',
|
||||
'points',
|
||||
],
|
||||
defaultNS: 'common',
|
||||
|
||||
detection: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
@@ -34,6 +35,7 @@ const uniqueBy = (items, getId) => [
|
||||
|
||||
export const GlobalSearchProvider = ({ children }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation('settings')
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery('(max-width:768px)')
|
||||
@@ -110,6 +112,7 @@ export const GlobalSearchProvider = ({ children }) => {
|
||||
labels,
|
||||
members,
|
||||
isParent: isParentUser(profile),
|
||||
t,
|
||||
choresById: new Map(chores.map(item => [String(item.id), item])),
|
||||
projectsById: new Map(projects.map(item => [String(item.id), item])),
|
||||
membersById: new Map(members.map(item => [String(item.userId), item])),
|
||||
@@ -127,7 +130,7 @@ export const GlobalSearchProvider = ({ children }) => {
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [queryClient])
|
||||
}, [queryClient, t])
|
||||
|
||||
const openSearch = useCallback(
|
||||
(query = '') => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
|
||||
|
||||
const stripHtml = value => {
|
||||
if (!value) return ''
|
||||
if (typeof globalThis.document === 'undefined')
|
||||
@@ -17,26 +19,6 @@ const HISTORY_STATUS = {
|
||||
6: 'rescheduled',
|
||||
}
|
||||
|
||||
const SETTINGS = [
|
||||
['profile', 'Profile', 'Name, avatar and personal details'],
|
||||
['circle', 'Circle', 'Members and household settings', true],
|
||||
['account', 'Account', 'Subscription and account management', true],
|
||||
['subaccounts', 'Subaccounts', 'Manage child accounts'],
|
||||
['notifications', 'Notifications', 'Reminders and notification preferences'],
|
||||
['mfa', 'Multi-factor authentication', 'Secure your account', true],
|
||||
['apitokens', 'API tokens', 'Manage integrations and access tokens', true],
|
||||
['storage', 'Storage', 'Files, backups and device storage'],
|
||||
['sidepanel', 'Side panel', 'Customize navigation'],
|
||||
['theme', 'Appearance', 'Theme, dark mode and colors'],
|
||||
['localization', 'Language and region', 'Language, dates and time formats'],
|
||||
[
|
||||
'advanced',
|
||||
'Advanced settings',
|
||||
'Offline support, webhooks and application behavior',
|
||||
],
|
||||
['developer', 'Developer settings', 'Diagnostics and experimental tools'],
|
||||
]
|
||||
|
||||
const providers = []
|
||||
|
||||
export const registerSearchProvider = provider => {
|
||||
@@ -166,15 +148,15 @@ registerSearchProvider({
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'settings',
|
||||
getDocuments: ({ isParent }) =>
|
||||
SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map(
|
||||
([id, title, description]) =>
|
||||
getDocuments: ({ isParent, t }) =>
|
||||
SETTINGS_SECTIONS.filter(({ parentOnly }) => !parentOnly || isParent).map(
|
||||
({ id }) =>
|
||||
document('settings', {
|
||||
id: `setting:${id}`,
|
||||
entityId: id,
|
||||
title,
|
||||
title: t(`overview.sections.${id}.title`),
|
||||
subtitle: 'Settings',
|
||||
body: description,
|
||||
body: t(`overview.sections.${id}.description`),
|
||||
keywords: `preferences configuration ${id}`,
|
||||
route: `/settings/${id}`,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Alert, Box } from '@mui/joy'
|
||||
import PropTypes from 'prop-types'
|
||||
import { createContext, useCallback, useContext, useState } from 'react'
|
||||
import Z_INDEX from '../constants/zIndex'
|
||||
|
||||
const FADE_DURATION = 400 // ms
|
||||
const ALERT_DURATION = 5000 // ms
|
||||
@@ -48,7 +47,7 @@ export const AlertsProvider = ({ children }) => {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: Z_INDEX.ALERTS,
|
||||
zIndex: 'var(--joy-zIndex-snackbar)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { authButtonSx, authInputSx } from './authStyles'
|
||||
|
||||
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
|
||||
@@ -40,12 +41,13 @@ export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
|
||||
)
|
||||
|
||||
export const AuthPasswordField = ({
|
||||
label = 'Password',
|
||||
label,
|
||||
error,
|
||||
helper,
|
||||
sx,
|
||||
...inputProps
|
||||
}) => {
|
||||
const { t } = useTranslation('auth')
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -60,7 +62,7 @@ export const AuthPasswordField = ({
|
||||
color='neutral'
|
||||
size='sm'
|
||||
tabIndex={-1}
|
||||
aria-label={visible ? 'Hide password' : 'Show password'}
|
||||
aria-label={visible ? t('hidePassword') : t('showPassword')}
|
||||
onClick={() => setVisible(v => !v)}
|
||||
sx={{ borderRadius: '8px' }}
|
||||
>
|
||||
@@ -110,27 +112,31 @@ export const SocialButton = ({ icon, children, sx, ...props }) => (
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const AuthDivider = ({ children = 'or' }) => (
|
||||
<Box
|
||||
role='separator'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
my: 2.5,
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
bgcolor: 'divider',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
export const AuthDivider = ({ children }) => {
|
||||
const { t } = useTranslation('auth')
|
||||
|
||||
return (
|
||||
<Box
|
||||
role='separator'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
my: 2.5,
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
bgcolor: 'divider',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
{children ?? t('or')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const LegalLinks = () => (
|
||||
<Typography
|
||||
|
||||
@@ -10,16 +10,18 @@ import { apiClient } from '../../utils/ApiClient'
|
||||
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
||||
import { GetUserProfile } from '../../utils/Fetcher'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const AuthenticationLoading = () => {
|
||||
const { t } = useTranslation('auth')
|
||||
const { refetch: refetchUserProfile } = useUserProfile()
|
||||
const Navigate = useNavigate()
|
||||
const hasCalledHandleOAuth2 = useRef(false)
|
||||
const [message, setMessage] = useState('Signing you in')
|
||||
const [subMessage, setSubMessage] = useState('This will only take a moment.')
|
||||
const [message, setMessage] = useState(t('authenticating.signingIn'))
|
||||
const [subMessage, setSubMessage] = useState(t('authenticating.signingInSub'))
|
||||
const [status, setStatus] = useState('pending')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
@@ -31,8 +33,8 @@ const AuthenticationLoading = () => {
|
||||
// suppress a genuine session expiry later on.
|
||||
handleOAuth2().finally(endOAuthExchange)
|
||||
} else if (provider !== 'oauth2') {
|
||||
setMessage('Unknown sign-in provider')
|
||||
setSubMessage('Please contact support.')
|
||||
setMessage(t('authenticating.unknownProvider'))
|
||||
setSubMessage(t('authenticating.contactSupport'))
|
||||
setStatus('error')
|
||||
}
|
||||
return endOAuthExchange
|
||||
@@ -71,8 +73,8 @@ const AuthenticationLoading = () => {
|
||||
const handleMFAClose = () => {
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Two-factor authentication was cancelled.')
|
||||
setMessage(t('authenticating.signInFailed'))
|
||||
setSubMessage(t('authenticating.mfaCancelled'))
|
||||
setStatus('error')
|
||||
}
|
||||
|
||||
@@ -85,8 +87,8 @@ const AuthenticationLoading = () => {
|
||||
const storedState = localStorage.getItem('authState')
|
||||
|
||||
if (returnedState !== storedState) {
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('The sign-in request could not be verified.')
|
||||
setMessage(t('authenticating.signInFailed'))
|
||||
setSubMessage(t('authenticating.requestNotVerified'))
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -112,8 +114,8 @@ const AuthenticationLoading = () => {
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Authentication failed')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Please try again.')
|
||||
setMessage(t('authenticating.signInFailed'))
|
||||
setSubMessage(t('authenticating.tryAgain'))
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -122,22 +124,22 @@ const AuthenticationLoading = () => {
|
||||
|
||||
if (data.mfaRequired) {
|
||||
if (!data.sessionToken) {
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('The MFA session is missing. Please try again.')
|
||||
setMessage(t('authenticating.signInFailed'))
|
||||
setSubMessage(t('authenticating.mfaSessionMissing'))
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
setMessage('Two-factor authentication')
|
||||
setSubMessage('Verify your login to continue.')
|
||||
setMessage(t('authenticating.twoFactor'))
|
||||
setSubMessage(t('authenticating.verifyToContinue'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.token && !data.access_token) {
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('No valid authentication token was returned.')
|
||||
setMessage(t('authenticating.signInFailed'))
|
||||
setSubMessage(t('authenticating.noToken'))
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -158,8 +160,8 @@ const AuthenticationLoading = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication request failed', error)
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Please try again.')
|
||||
setMessage(t('authenticating.signInFailed'))
|
||||
setSubMessage(t('authenticating.tryAgain'))
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
@@ -188,7 +190,7 @@ const AuthenticationLoading = () => {
|
||||
fullWidth
|
||||
sx={authButtonSx}
|
||||
>
|
||||
Back to sign in
|
||||
{t('authenticating.backToSignIn')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -199,8 +201,8 @@ const AuthenticationLoading = () => {
|
||||
sessionToken={mfaSessionToken}
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={() => {
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Two-factor authentication failed. Please try again.')
|
||||
setMessage(t('authenticating.signInFailed'))
|
||||
setSubMessage(t('authenticating.mfaFailed'))
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import MarkEmailReadOutlined from '@mui/icons-material/MarkEmailReadOutlined'
|
||||
import { Box, Button, Link, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ResetPassword } from '../../utils/Fetcher'
|
||||
@@ -12,6 +13,7 @@ const isInvalidEmail = email =>
|
||||
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
||||
|
||||
const ForgotPasswordView = () => {
|
||||
const { t } = useTranslation('auth')
|
||||
const navigate = useNavigate()
|
||||
const [resetStatusOk, setResetStatusOk] = useState(null)
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -40,21 +42,21 @@ const ForgotPasswordView = () => {
|
||||
setResetStatusOk(true)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Reset Email Sent',
|
||||
message: 'Check your email for password reset instructions',
|
||||
title: t('resetEmailSent'),
|
||||
message: t('resetEmailSentMsg'),
|
||||
})
|
||||
} else {
|
||||
setResetStatusOk(false)
|
||||
showError({
|
||||
title: 'Reset Failed',
|
||||
message: 'Failed to send reset email, please try again later',
|
||||
title: t('resetFailed'),
|
||||
message: t('resetFailedMsg'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setResetStatusOk(false)
|
||||
showError({
|
||||
title: 'Reset Failed',
|
||||
message: 'Failed to send reset email, please try again later',
|
||||
title: t('resetFailed'),
|
||||
message: t('resetFailedMsg'),
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -79,7 +81,7 @@ const ForgotPasswordView = () => {
|
||||
if (resetStatusOk !== null) {
|
||||
return (
|
||||
<AuthShell
|
||||
title='Check your email'
|
||||
title={t('checkYourEmail')}
|
||||
subtitle={`If an account exists for ${email}, we've sent instructions for resetting your password.`}
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
@@ -101,7 +103,7 @@ const ForgotPasswordView = () => {
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
{t('backToSignIn')}
|
||||
</Button>
|
||||
</Box>
|
||||
</AuthShell>
|
||||
@@ -110,7 +112,7 @@ const ForgotPasswordView = () => {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title='Reset your password'
|
||||
title={t('resetYourPassword')}
|
||||
subtitle="Enter your email and we'll send you a link to get back into your account."
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
@@ -126,7 +128,7 @@ const ForgotPasswordView = () => {
|
||||
name='email'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
placeholder='you@example.com'
|
||||
placeholder={t('emailPlaceholder')}
|
||||
autoFocus
|
||||
value={email}
|
||||
error={emailError}
|
||||
@@ -152,7 +154,7 @@ const ForgotPasswordView = () => {
|
||||
underline='hover'
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
{t('backToSignIn')}
|
||||
</Link>
|
||||
</Typography>
|
||||
</AuthShell>
|
||||
|
||||
@@ -12,10 +12,12 @@ import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { AuthSubmitButton, AuthTextField } from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 8000
|
||||
|
||||
const LoginSettings = () => {
|
||||
const { t } = useTranslation('auth')
|
||||
const Navigate = useNavigate()
|
||||
const { refetch: refetchResource } = useResource()
|
||||
const [serverURL, setServerURL] = React.useState('')
|
||||
@@ -105,7 +107,7 @@ const LoginSettings = () => {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'Hostname could not be resolved. Check the URL for typos or verify DNS.',
|
||||
t('server.dnsFailed'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -118,7 +120,7 @@ const LoginSettings = () => {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'Unable to reach the server. Check the URL, port, and network connection.',
|
||||
t('server.unreachable'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +128,7 @@ const LoginSettings = () => {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'Unable to reach the server. Check the URL, port, and network connection.',
|
||||
t('server.unreachable'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,7 +146,7 @@ const LoginSettings = () => {
|
||||
if (!isValidURL(trimmedURL)) {
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
|
||||
t('server.invalidUrl'),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -187,7 +189,7 @@ const LoginSettings = () => {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title='Server settings'
|
||||
title={t('serverSettings')}
|
||||
subtitle='Point the app at your own self-hosted Donetick server.'
|
||||
>
|
||||
<Box
|
||||
@@ -196,7 +198,7 @@ const LoginSettings = () => {
|
||||
sx={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<AuthTextField
|
||||
label='Server URL'
|
||||
label={t('server.url')}
|
||||
id='serverURL'
|
||||
name='serverURL'
|
||||
inputMode='url'
|
||||
@@ -243,7 +245,7 @@ const LoginSettings = () => {
|
||||
startDecorator={<CheckCircleOutlineIcon />}
|
||||
sx={{ mt: 2, borderRadius: '12px' }}
|
||||
>
|
||||
Connected. Taking you to sign in...
|
||||
{t('serverConnected')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -290,7 +292,7 @@ const LoginSettings = () => {
|
||||
level='body-xs'
|
||||
sx={{ mt: 2.5, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Changing the server clears locally cached data on this device.
|
||||
{t('serverChangeWarning')}
|
||||
</Typography>
|
||||
</AuthShell>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Avatar, Box, Button, IconButton, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
|
||||
@@ -84,6 +85,7 @@ const SegmentedControl = ({ value, onChange, options }) => (
|
||||
)
|
||||
|
||||
const LoginView = () => {
|
||||
const { t } = useTranslation('auth')
|
||||
// Use React Query client directly to invalidate the user profile query
|
||||
const queryClient = useQueryClient()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -158,23 +160,23 @@ const LoginView = () => {
|
||||
if (loginType === 'sub') {
|
||||
if (!parentUsername.trim()) {
|
||||
showError({
|
||||
title: 'Validation Error',
|
||||
message: 'Primary username is required for sub account login',
|
||||
title: t('validationError'),
|
||||
message: t('primaryUsernameRequired'),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!childName.trim()) {
|
||||
showError({
|
||||
title: 'Validation Error',
|
||||
message: 'Sub account name is required for sub account login',
|
||||
title: t('validationError'),
|
||||
message: t('subNameRequired'),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!username.trim()) {
|
||||
showError({
|
||||
title: 'Validation Error',
|
||||
message: 'Username is required',
|
||||
title: t('validationError'),
|
||||
message: t('usernameRequired'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -182,8 +184,8 @@ const LoginView = () => {
|
||||
|
||||
if (!password) {
|
||||
showError({
|
||||
title: 'Validation Error',
|
||||
message: 'Password is required',
|
||||
title: t('validationError'),
|
||||
message: t('passwordRequired'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -200,7 +202,7 @@ const LoginView = () => {
|
||||
result = await authLogin({ username: actualUsername, password })
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Login Failed',
|
||||
title: t('loginFailed'),
|
||||
message: error?.message || 'An error occurred, please try again',
|
||||
})
|
||||
return
|
||||
@@ -227,8 +229,8 @@ const LoginView = () => {
|
||||
}
|
||||
} else {
|
||||
showError({
|
||||
title: 'Login Failed',
|
||||
message: result.error || 'An error occurred, please try again',
|
||||
title: t('loginFailed'),
|
||||
message: result.error || t('genericError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -298,15 +300,15 @@ const LoginView = () => {
|
||||
} else {
|
||||
const providerName = provider === 'apple' ? 'Apple' : 'Google'
|
||||
showError({
|
||||
title: `${providerName} Login Failed`,
|
||||
message: `Couldn't log in with ${providerName}, please try again`,
|
||||
title: t('providerLoginFailed', { provider: providerName }),
|
||||
message: t('providerLoginFailedMsg', { provider: providerName }),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const providerName = provider === 'apple' ? 'Apple' : 'Google'
|
||||
showError({
|
||||
title: `${providerName} Login Error`,
|
||||
message: 'Network error occurred, please try again',
|
||||
title: t('providerLoginError', { provider: providerName }),
|
||||
message: t('networkError'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -350,7 +352,7 @@ const LoginView = () => {
|
||||
|
||||
const handleMFAError = errorMessage => {
|
||||
showError({
|
||||
title: 'Two-Factor Authentication Failed',
|
||||
title: t('mfaFailed'),
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
@@ -398,8 +400,8 @@ const LoginView = () => {
|
||||
} catch (error) {
|
||||
console.error('Failed to open OAuth browser:', error)
|
||||
showError({
|
||||
title: 'OAuth Error',
|
||||
message: 'Failed to open authentication browser',
|
||||
title: t('oauthError'),
|
||||
message: t('oauthBrowserFailed'),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -439,7 +441,7 @@ const LoginView = () => {
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
aria-label='Server settings'
|
||||
aria-label={t('serverSettings')}
|
||||
onClick={() => Navigate('/login/settings')}
|
||||
>
|
||||
<SettingsOutlined />
|
||||
@@ -465,7 +467,7 @@ const LoginView = () => {
|
||||
<Typography level='title-md'>{displayName}</Typography>
|
||||
{getUserDisplayInfo(userProfile).userType === 'child' && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
Sub Account
|
||||
{t('subAccount')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -486,7 +488,7 @@ const LoginView = () => {
|
||||
sx={authButtonSx}
|
||||
onClick={() => apiClient.handleLogout()}
|
||||
>
|
||||
Use a different account
|
||||
{t('useDifferentAccount')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -499,19 +501,19 @@ const LoginView = () => {
|
||||
value={loginType}
|
||||
onChange={handleLoginModeChange}
|
||||
options={[
|
||||
{ value: 'primary', label: 'Primary Account' },
|
||||
{ value: 'sub', label: 'Sub Account' },
|
||||
{ value: 'primary', label: t('primaryAccount') },
|
||||
{ value: 'sub', label: t('subAccount') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{loginType === 'primary' ? (
|
||||
<AuthTextField
|
||||
label='Username'
|
||||
label={t('username')}
|
||||
id='username'
|
||||
name='username'
|
||||
autoComplete='username'
|
||||
placeholder='Your username'
|
||||
placeholder={t('yourUsername')}
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
@@ -519,20 +521,20 @@ const LoginView = () => {
|
||||
) : (
|
||||
<>
|
||||
<AuthTextField
|
||||
label='Primary account username'
|
||||
label={t('primaryAccountUsernameLabel')}
|
||||
id='parentUsername'
|
||||
name='parentUsername'
|
||||
autoComplete='username'
|
||||
placeholder='Enter primary account username'
|
||||
placeholder={t('primaryUsernamePlaceholder')}
|
||||
autoFocus
|
||||
value={parentUsername}
|
||||
onChange={e => setParentUsername(e.target.value)}
|
||||
/>
|
||||
<AuthTextField
|
||||
label='Sub account name'
|
||||
label={t('subAccountNameLabel')}
|
||||
id='childName'
|
||||
name='childName'
|
||||
placeholder='Enter sub account name'
|
||||
placeholder={t('subNamePlaceholder')}
|
||||
value={childName}
|
||||
onChange={e => setChildName(e.target.value)}
|
||||
/>
|
||||
@@ -544,7 +546,8 @@ const LoginView = () => {
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='current-password'
|
||||
placeholder='Enter your password'
|
||||
label={t('passwordLabel')}
|
||||
placeholder={t('loginPasswordPlaceholder')}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
@@ -556,7 +559,7 @@ const LoginView = () => {
|
||||
underline='hover'
|
||||
onClick={handleForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
{t('forgotPassword')}
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -568,7 +571,7 @@ const LoginView = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasSocialOptions && <AuthDivider>or continue with</AuthDivider>}
|
||||
{hasSocialOptions && <AuthDivider>{t('orContinueWith')}</AuthDivider>}
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{showSocialLogin && !Capacitor.isNativePlatform() && (
|
||||
@@ -584,7 +587,7 @@ const LoginView = () => {
|
||||
}}
|
||||
onReject={() => {
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
title: t('googleLoginFailed'),
|
||||
message: "Couldn't log in with Google, please try again",
|
||||
})
|
||||
}}
|
||||
@@ -608,7 +611,7 @@ const LoginView = () => {
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error)
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
title: t('googleLoginFailed'),
|
||||
message: `Couldn't log in with Google, please try again${
|
||||
error?.message ? `: ${error.message}` : ''
|
||||
}`,
|
||||
@@ -637,7 +640,7 @@ const LoginView = () => {
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
title: t('appleLoginFailed'),
|
||||
message: "Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
@@ -670,7 +673,7 @@ const LoginView = () => {
|
||||
underline='hover'
|
||||
onClick={() => Navigate('/signup')}
|
||||
>
|
||||
Create one
|
||||
{t('createOne')}
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { VerifyMFA } from '../../utils/Fetcher'
|
||||
import { authInputSx } from './authStyles'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MFAVerificationModal = ({
|
||||
open,
|
||||
@@ -13,6 +14,7 @@ const MFAVerificationModal = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation('auth')
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [isBackupCode, setIsBackupCode] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -21,7 +23,7 @@ const MFAVerificationModal = ({
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode.trim()) {
|
||||
setError('Please enter a verification code')
|
||||
setError(t('mfaModal.codeRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,14 +39,14 @@ const MFAVerificationModal = ({
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
const message =
|
||||
errorData.message || 'Invalid verification code. Please try again.'
|
||||
errorData.message || t('mfaModal.invalidCode')
|
||||
setError(message)
|
||||
onError?.(message)
|
||||
}
|
||||
} catch (error) {
|
||||
// A wrong code is shown inline; a failed request is escalated to the
|
||||
// caller so it can surface a toast instead of looking like a bad code.
|
||||
const message = 'Failed to verify code. Please try again.'
|
||||
const message = t('mfaModal.verifyFailed')
|
||||
setError(message)
|
||||
onError?.(message)
|
||||
console.error('MFA verification error:', error)
|
||||
@@ -73,23 +75,23 @@ const MFAVerificationModal = ({
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size='md'
|
||||
title='Two-factor authentication'
|
||||
title={t('mfaModal.title')}
|
||||
description={
|
||||
isBackupCode
|
||||
? 'Enter one of the backup codes you saved when setting up two-factor authentication.'
|
||||
: 'Enter the 6-digit code from your authenticator app.'
|
||||
? t('mfaModal.backupHint')
|
||||
: t('mfaModal.codeHint')
|
||||
}
|
||||
closeOnBackdrop={!loading}
|
||||
closeOnEscape={!loading}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
label: t('common:cancel'),
|
||||
onClick: handleClose,
|
||||
disabled: loading,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Verify & Sign In',
|
||||
label: t('mfaModal.verify'),
|
||||
onClick: handleVerify,
|
||||
loading,
|
||||
disabled: !verificationCode.trim(),
|
||||
@@ -105,12 +107,12 @@ const MFAVerificationModal = ({
|
||||
level='body-sm'
|
||||
sx={{ display: 'block', fontWeight: 600, mb: 0.75 }}
|
||||
>
|
||||
{isBackupCode ? 'Backup code' : 'Verification code'}
|
||||
{isBackupCode ? t('mfaModal.backupLabel') : t('mfaModal.codeLabel')}
|
||||
</Typography>
|
||||
<Input
|
||||
id='mfa-code'
|
||||
size='lg'
|
||||
placeholder={isBackupCode ? 'Enter backup code' : '000000'}
|
||||
placeholder={isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'}
|
||||
value={verificationCode}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -157,8 +159,8 @@ const MFAVerificationModal = ({
|
||||
}}
|
||||
>
|
||||
{isBackupCode
|
||||
? 'Use authenticator app instead'
|
||||
: 'Use a backup code instead'}
|
||||
? t('mfaModal.useAuthenticator')
|
||||
: t('mfaModal.useBackup')}
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
@@ -167,7 +169,7 @@ const MFAVerificationModal = ({
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Each backup code can only be used once.
|
||||
{t('mfaModal.backupOnce')}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import AuthShell from './AuthShell'
|
||||
|
||||
const SignupView = () => {
|
||||
const { t } = useTranslation('auth')
|
||||
const [username, setUsername] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const Navigate = useNavigate()
|
||||
@@ -38,7 +40,7 @@ const SignupView = () => {
|
||||
showError({
|
||||
title: 'Almost there',
|
||||
message:
|
||||
'Your account was created, but signing in failed. Please sign in.',
|
||||
t('signupSignInFailed'),
|
||||
})
|
||||
Navigate('/login')
|
||||
return
|
||||
@@ -70,36 +72,36 @@ const SignupView = () => {
|
||||
let isValid = true
|
||||
|
||||
if (!username.trim()) {
|
||||
setUsernameError('Username is required')
|
||||
setUsernameError(t('usernameRequired'))
|
||||
isValid = false
|
||||
}
|
||||
if (username.length < 4) {
|
||||
setUsernameError('Username must be at least 4 characters')
|
||||
setUsernameError(t('usernameMinLength'))
|
||||
isValid = false
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setEmailError('Invalid email address')
|
||||
setEmailError(t('invalidEmail'))
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setPasswordError('Password must be between 8 and 64 characters')
|
||||
setPasswordError(t('passwordLength'))
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if (password.length > 64) {
|
||||
setPasswordError('Password must be between 8 and 64 characters')
|
||||
setPasswordError(t('passwordLength'))
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if (!displayName.trim()) {
|
||||
setDisplayNameError('Display name is required')
|
||||
setDisplayNameError(t('displayNameRequired'))
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// display name should only contain letters and spaces and numbers:
|
||||
if (!/^[a-zA-Z0-9 ]+$/.test(displayName)) {
|
||||
setDisplayNameError('Display name can only contain letters and numbers')
|
||||
setDisplayNameError(t('displayNameChars'))
|
||||
isValid = false
|
||||
}
|
||||
|
||||
@@ -125,14 +127,14 @@ const SignupView = () => {
|
||||
handleLogin(username, password)
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: 'Signup disabled, please contact admin',
|
||||
title: t('signupFailed'),
|
||||
message: t('signupDisabled'),
|
||||
})
|
||||
} else {
|
||||
console.log('Signup failed')
|
||||
response.json().then(res => {
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
title: t('signupFailed'),
|
||||
message: res.error || 'An error occurred during signup',
|
||||
})
|
||||
})
|
||||
@@ -143,7 +145,7 @@ const SignupView = () => {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title='Create your account'
|
||||
title={t('createYourAccount')}
|
||||
subtitle={
|
||||
getPendingInvite()
|
||||
? 'Create an account and we’ll send your circle join request right after.'
|
||||
@@ -158,11 +160,11 @@ const SignupView = () => {
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<AuthTextField
|
||||
label='Display name'
|
||||
label={t('displayNameLabel')}
|
||||
id='displayName'
|
||||
name='displayName'
|
||||
autoComplete='name'
|
||||
placeholder='How others see your name'
|
||||
placeholder={t('displayNamePlaceholder')}
|
||||
autoFocus
|
||||
value={displayName}
|
||||
error={displayNameError}
|
||||
@@ -173,11 +175,11 @@ const SignupView = () => {
|
||||
/>
|
||||
|
||||
<AuthTextField
|
||||
label='Username'
|
||||
label={t('username')}
|
||||
id='username'
|
||||
name='username'
|
||||
autoComplete='username'
|
||||
placeholder='lowercase letters, numbers, dot and dash'
|
||||
placeholder={t('usernameHint')}
|
||||
value={username}
|
||||
error={usernameError}
|
||||
onChange={e => {
|
||||
@@ -187,12 +189,12 @@ const SignupView = () => {
|
||||
/>
|
||||
|
||||
<AuthTextField
|
||||
label='Email'
|
||||
label={t('email')}
|
||||
id='email'
|
||||
name='email'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
placeholder='you@example.com'
|
||||
placeholder={t('emailPlaceholder')}
|
||||
value={email}
|
||||
error={emailError}
|
||||
onChange={e => {
|
||||
@@ -205,10 +207,11 @@ const SignupView = () => {
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='new-password'
|
||||
placeholder='At least 8 characters'
|
||||
label={t('passwordLabel')}
|
||||
placeholder={t('signupPasswordPlaceholder')}
|
||||
value={password}
|
||||
error={passwordError}
|
||||
helper='Use 8 to 64 characters.'
|
||||
helper={t('signupPasswordHelper')}
|
||||
onChange={e => {
|
||||
setPasswordError(null)
|
||||
setPassword(e.target.value)
|
||||
@@ -216,14 +219,14 @@ const SignupView = () => {
|
||||
/>
|
||||
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||
Create account
|
||||
{t('createAccountButton')}
|
||||
</AuthSubmitButton>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
By creating an account you agree to our Terms of Service and Privacy
|
||||
{t('termsShort')}
|
||||
Policy.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Button } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
@@ -9,6 +10,7 @@ import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
|
||||
const UpdatePasswordView = () => {
|
||||
const { t } = useTranslation('auth')
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
@@ -67,23 +69,22 @@ const UpdatePasswordView = () => {
|
||||
if (response.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Password Updated',
|
||||
message:
|
||||
'Your password has been updated successfully. Redirecting to login...',
|
||||
title: t('passwordUpdated'),
|
||||
message: t('passwordUpdatedMsg'),
|
||||
})
|
||||
setTimeout(() => {
|
||||
navigate('/login')
|
||||
}, 3000)
|
||||
} else {
|
||||
showError({
|
||||
title: 'Password Update Failed',
|
||||
message: 'Failed to update password, please try again later',
|
||||
title: t('passwordUpdateFailed'),
|
||||
message: t('passwordUpdateFailedMsg'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Password Update Failed',
|
||||
message: 'Failed to update password, please try again later',
|
||||
title: t('passwordUpdateFailed'),
|
||||
message: t('passwordUpdateFailedMsg'),
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -93,7 +94,7 @@ const UpdatePasswordView = () => {
|
||||
if (!verificationCode) {
|
||||
return (
|
||||
<AuthShell
|
||||
title='This link is not valid'
|
||||
title={t('linkInvalid')}
|
||||
subtitle='The password reset link is incomplete or has already been used. Request a new one to continue.'
|
||||
footer={<LegalLinks />}
|
||||
showLogo
|
||||
@@ -106,7 +107,7 @@ const UpdatePasswordView = () => {
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/forgot-password')}
|
||||
>
|
||||
Request a new link
|
||||
{t('requestNewLink')}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -116,7 +117,7 @@ const UpdatePasswordView = () => {
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
{t('backToSignIn')}
|
||||
</Button>
|
||||
</Box>
|
||||
</AuthShell>
|
||||
@@ -125,7 +126,7 @@ const UpdatePasswordView = () => {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title='Set a new password'
|
||||
title={t('setNewPassword')}
|
||||
subtitle='Choose a password you have not used on this account before.'
|
||||
footer={<LegalLinks />}
|
||||
// Reached from an emailed link, usually in a browser: an unbranded page
|
||||
@@ -138,7 +139,7 @@ const UpdatePasswordView = () => {
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<AuthPasswordField
|
||||
label='New password'
|
||||
label={t('newPassword')}
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='new-password'
|
||||
@@ -151,18 +152,18 @@ const UpdatePasswordView = () => {
|
||||
/>
|
||||
|
||||
<AuthPasswordField
|
||||
label='Confirm new password'
|
||||
label={t('confirmNewPassword')}
|
||||
id='passwordConfirm'
|
||||
name='passwordConfirm'
|
||||
autoComplete='new-password'
|
||||
placeholder='Re-enter your password'
|
||||
placeholder={t('reenterPassword')}
|
||||
value={passwordConfirm}
|
||||
error={passwordConfirmationError}
|
||||
onChange={handlePasswordConfirmChange}
|
||||
/>
|
||||
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||
Save password
|
||||
{t('savePassword')}
|
||||
</AuthSubmitButton>
|
||||
|
||||
<Button
|
||||
@@ -174,7 +175,7 @@ const UpdatePasswordView = () => {
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Cancel
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
</Box>
|
||||
</AuthShell>
|
||||
|
||||
@@ -38,16 +38,17 @@ import {
|
||||
ButtonGroup,
|
||||
Chip,
|
||||
Divider,
|
||||
Dropdown,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AppModal from '../../../components/common/AppModal'
|
||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||
import { Z_INDEX } from '../../../constants/zIndex'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
@@ -229,9 +230,8 @@ const ChoreToolbar = ({
|
||||
const [localSelections, setLocalSelections] = useState(defaultSelections())
|
||||
const [savingFilter, setSavingFilter] = useState(false)
|
||||
const [saveFilterName, setSaveFilterName] = useState('')
|
||||
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
|
||||
const [saveMenuOpen, setSaveMenuOpen] = useState(false)
|
||||
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
|
||||
const saveMenuRef = useRef(null)
|
||||
const activeConditions = selectionsToConditions(localSelections)
|
||||
|
||||
// ── badge counts ─────────────────────────────────────────────────────────────
|
||||
@@ -419,13 +419,13 @@ const ChoreToolbar = ({
|
||||
}
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
}
|
||||
}, [filterSheetOpen, savingFilter, activeConditions.length])
|
||||
|
||||
@@ -504,7 +504,7 @@ const ChoreToolbar = ({
|
||||
onFilterSaved?.(editingSavedFilter.name)
|
||||
})
|
||||
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(false)
|
||||
}
|
||||
|
||||
@@ -681,7 +681,7 @@ const ChoreToolbar = ({
|
||||
open={filterSheetOpen}
|
||||
isMobile
|
||||
onClose={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
maxHeight='92vh'
|
||||
@@ -747,7 +747,7 @@ const ChoreToolbar = ({
|
||||
disabled={!hasAnyActive && activeConditions.length === 0}
|
||||
onClick={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
onClearAllFilters?.()
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
@@ -756,33 +756,29 @@ const ChoreToolbar = ({
|
||||
</Button>
|
||||
|
||||
{activeConditions.length > 0 ? (
|
||||
<>
|
||||
<Dropdown
|
||||
open={saveMenuOpen}
|
||||
onOpenChange={(_event, isOpen) => setSaveMenuOpen(isOpen)}
|
||||
>
|
||||
<ButtonGroup variant='solid' color='primary'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{resultCount != null ? `Show ${resultCount}` : 'Done'}
|
||||
</Button>
|
||||
<IconButton
|
||||
ref={saveMenuRef}
|
||||
<MenuButton
|
||||
slots={{ root: IconButton }}
|
||||
aria-label='More save options'
|
||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
</IconButton>
|
||||
</MenuButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Menu
|
||||
anchorEl={saveMenuAnchorEl}
|
||||
open={Boolean(saveMenuAnchorEl)}
|
||||
onClose={() => setSaveMenuAnchorEl(null)}
|
||||
placement='top-end'
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
|
||||
>
|
||||
<Menu placement='top-end'>
|
||||
<MenuItem
|
||||
onClick={handleUpdateFilter}
|
||||
disabled={!editingSavedFilter}
|
||||
@@ -792,7 +788,7 @@ const ChoreToolbar = ({
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setSaveFilterName(
|
||||
editingSavedFilter
|
||||
? `${editingSavedFilter.name} Copy`
|
||||
@@ -805,13 +801,13 @@ const ChoreToolbar = ({
|
||||
Save as New Filter
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
formatErrorReport,
|
||||
} from '../service/ErrorReportService'
|
||||
import ErrorReportModal from './Modals/ErrorReportModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getErrorKind = error => {
|
||||
if (!error)
|
||||
@@ -55,6 +56,7 @@ const safeMessage = error => {
|
||||
}
|
||||
|
||||
const Error = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const error = useRouteError()
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -192,7 +194,7 @@ const Error = () => {
|
||||
textAlign='center'
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
Something went wrong
|
||||
{t('errorScreen.title')}
|
||||
</Typography>
|
||||
|
||||
{/* Error message */}
|
||||
@@ -207,8 +209,7 @@ const Error = () => {
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{message ??
|
||||
'An unexpected error occurred. Try reloading — it usually fixes it.'}
|
||||
{message ?? t('errorScreen.fallback')}
|
||||
</Typography>
|
||||
|
||||
{/* Primary CTA */}
|
||||
@@ -220,7 +221,7 @@ const Error = () => {
|
||||
onClick={() => window.location.reload()}
|
||||
sx={{ width: '100%', mb: 1.5 }}
|
||||
>
|
||||
Try again
|
||||
{t('errorScreen.tryAgain')}
|
||||
</Button>
|
||||
|
||||
{/* Reporting is one tap from the failure, where the context is still
|
||||
@@ -246,7 +247,7 @@ const Error = () => {
|
||||
size='lg'
|
||||
startDecorator={<HomeRounded />}
|
||||
>
|
||||
Home
|
||||
{t('errorScreen.home')}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
@@ -255,7 +256,7 @@ const Error = () => {
|
||||
color='neutral'
|
||||
size='lg'
|
||||
>
|
||||
Login
|
||||
{t('errorScreen.login')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -293,7 +294,9 @@ const Error = () => {
|
||||
}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} error details
|
||||
{showDetails
|
||||
? t('errorScreen.hideDetails')
|
||||
: t('errorScreen.showDetails')}
|
||||
</Button>
|
||||
|
||||
{showDetails && (
|
||||
@@ -312,7 +315,7 @@ const Error = () => {
|
||||
color='neutral'
|
||||
onClick={handleCopy}
|
||||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||
title='Copy to clipboard'
|
||||
title={t('errorScreen.copyToClipboard')}
|
||||
>
|
||||
<ContentCopyRounded fontSize='small' />
|
||||
</IconButton>
|
||||
@@ -347,7 +350,7 @@ const Error = () => {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
size='sm'
|
||||
>
|
||||
Error details copied to clipboard
|
||||
{t('errorScreen.copied')}
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Box, Button, Container, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Logo from '../Logo'
|
||||
const Home = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const Navigate = useNavigate()
|
||||
const getCurrentUser = () => {
|
||||
return JSON.parse(localStorage.getItem('user'))
|
||||
@@ -36,7 +38,7 @@ const Home = () => {
|
||||
Navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Get Started!
|
||||
{t('getStarted')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import { useLabels } from './LabelQueries'
|
||||
|
||||
const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
|
||||
const { t } = useTranslation('labels')
|
||||
// Check if current user owns this label
|
||||
const isOwnedByCurrentUser = label.created_by === currentUserId
|
||||
|
||||
@@ -124,7 +126,7 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
{t('shared')}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
@@ -149,6 +151,7 @@ const LabelCardContent = ({ label, currentUserId, onToggleActions }) => {
|
||||
}
|
||||
|
||||
const LabelView = () => {
|
||||
const { t } = useTranslation('labels')
|
||||
const { data: labels, isLabelsLoading, isError } = useLabels()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
@@ -173,14 +176,11 @@ const LabelView = () => {
|
||||
const handleDeleteClicked = id => {
|
||||
setConfirmationModel({
|
||||
isOpen: true,
|
||||
title: 'Delete Label',
|
||||
|
||||
message:
|
||||
'Are you sure you want to delete this label? This will remove the label from all tasks.',
|
||||
|
||||
confirmText: 'Delete',
|
||||
title: t('delete.title'),
|
||||
message: t('delete.message'),
|
||||
confirmText: t('common:delete'),
|
||||
color: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
cancelText: t('common:cancel'),
|
||||
onClose: confirmed => {
|
||||
if (confirmed === true) {
|
||||
handleDeleteLabel(id)
|
||||
@@ -230,7 +230,7 @@ const LabelView = () => {
|
||||
if (isError) {
|
||||
return (
|
||||
<Typography color='danger' textAlign='center'>
|
||||
Failed to load labels. Please try again.
|
||||
{t('loadError')}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -244,12 +244,10 @@ const LabelView = () => {
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Labels
|
||||
{t('common:navigation.labels')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Manage your labels and organize your tasks effectively. Labels will
|
||||
be automatically shared with your circle if they are used on a
|
||||
shared task.
|
||||
{t('blurb')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -300,7 +298,7 @@ const LabelView = () => {
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -319,7 +317,7 @@ const LabelView = () => {
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Delete
|
||||
{t('common:delete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { FormLabel, Input } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import ConfirmationModal from './Inputs/ConfirmationModal'
|
||||
|
||||
function EditHistoryModal({ config, historyRecord }) {
|
||||
const { t } = useTranslation('history')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [completedDate, setCompletedDate] = useState('')
|
||||
@@ -40,12 +42,12 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
onClose={config?.onClose}
|
||||
size='lg'
|
||||
// fullWidth={true}
|
||||
title='Edit History'
|
||||
title={t('edit.title')}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: config.onClose }}
|
||||
secondary={{ label: t('common:cancel'), onClick: config.onClose }}
|
||||
primary={{
|
||||
label: 'Save',
|
||||
label: t('common:save'),
|
||||
onClick: () =>
|
||||
config.onSave({
|
||||
id: historyRecord.id,
|
||||
@@ -57,7 +59,7 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormLabel>Due Date</FormLabel>
|
||||
<FormLabel>{t('chores:dueDate')}</FormLabel>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={dueDate}
|
||||
@@ -66,7 +68,7 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
}}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<FormLabel>Completed Date</FormLabel>
|
||||
<FormLabel>{t('edit.completedDate')}</FormLabel>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
value={completedDate}
|
||||
@@ -75,12 +77,12 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
}}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<FormLabel>Note</FormLabel>
|
||||
<FormLabel>{t('edit.note')}</FormLabel>
|
||||
<Input
|
||||
fullWidth
|
||||
multiline
|
||||
label='Additional Notes'
|
||||
placeholder='Additional Notes'
|
||||
label={t('edit.additionalNotes')}
|
||||
placeholder={t('edit.additionalNotes')}
|
||||
value={notes}
|
||||
onChange={e => {
|
||||
if (e.target.value.trim() === '') {
|
||||
@@ -104,10 +106,10 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
}
|
||||
setIsDeleteModalOpen(false)
|
||||
},
|
||||
title: 'Delete History',
|
||||
message: 'Are you sure you want to delete this history?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
title: t('edit.deleteTitle'),
|
||||
message: t('edit.deleteMessage'),
|
||||
confirmText: t('common:delete'),
|
||||
cancelText: t('common:cancel'),
|
||||
color: 'danger',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
@@ -22,13 +23,13 @@ import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
0: { label: t('status.inProgress'), color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: t('status.completed'), color: 'success', icon: <Check /> },
|
||||
2: { label: t('status.skipped'), color: 'warning', icon: <Redo /> },
|
||||
3: { label: t('status.pendingApproval'), color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: t('status.rejected'), color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: t('status.missed'), color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: t('status.rescheduled'), color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const DetailRow = ({ icon, label, value, children }) => (
|
||||
@@ -55,6 +56,8 @@ const DetailRow = ({ icon, label, value, children }) => (
|
||||
)
|
||||
|
||||
const TimingBadge = ({ historyEntry }) => {
|
||||
const { t } = useTranslation('history')
|
||||
|
||||
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
|
||||
if ([0, 5, 6].includes(historyEntry.status)) return null
|
||||
|
||||
@@ -71,7 +74,7 @@ const TimingBadge = ({ historyEntry }) => {
|
||||
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
On Time
|
||||
{t('badge.onTime')}
|
||||
</Chip>
|
||||
)
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
@@ -103,6 +106,7 @@ const TimingBadge = ({ historyEntry }) => {
|
||||
}
|
||||
|
||||
function HistoryDetailModal({ config }) {
|
||||
const { t } = useTranslation('history')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { fmt } = useLocalization()
|
||||
const navigate = useNavigate()
|
||||
@@ -114,7 +118,9 @@ function HistoryDetailModal({ config }) {
|
||||
|
||||
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
|
||||
const isFirstSchedule = entry.status === 6 && !entry.dueDate
|
||||
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
|
||||
const statusLabel = isFirstSchedule
|
||||
? t('status.scheduled')
|
||||
: t(statusCfg.labelKey)
|
||||
const performer = performers.find(p => p.userId === entry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
|
||||
const isDifferentAssignee =
|
||||
@@ -138,7 +144,7 @@ function HistoryDetailModal({ config }) {
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
title='Activity Detail'
|
||||
title={t('detail.title')}
|
||||
footer={
|
||||
<ModalActions>
|
||||
{entry.choreId && (
|
||||
@@ -151,7 +157,7 @@ function HistoryDetailModal({ config }) {
|
||||
navigate(`/chores/${entry.choreId}`)
|
||||
}}
|
||||
>
|
||||
Open Task
|
||||
{t('detail.openTask')}
|
||||
</Button>
|
||||
)}
|
||||
{config?.onEdit && (
|
||||
@@ -159,7 +165,7 @@ function HistoryDetailModal({ config }) {
|
||||
startDecorator={<Edit sx={{ fontSize: 16 }} />}
|
||||
onClick={() => config.onEdit(entry)}
|
||||
>
|
||||
Edit Entry
|
||||
{t('detail.editEntry')}
|
||||
</Button>
|
||||
)}
|
||||
</ModalActions>
|
||||
@@ -196,7 +202,7 @@ function HistoryDetailModal({ config }) {
|
||||
{performer && (
|
||||
<DetailRow
|
||||
icon={<Check sx={{ fontSize: 16 }} />}
|
||||
label='Performed by'
|
||||
label={t('detail.performedBy')}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Avatar
|
||||
@@ -216,7 +222,7 @@ function HistoryDetailModal({ config }) {
|
||||
{isDifferentAssignee && assignedTo && (
|
||||
<DetailRow
|
||||
icon={<Person sx={{ fontSize: 16 }} />}
|
||||
label='Assigned to'
|
||||
label={t('detail.assignedTo')}
|
||||
value={assignedTo.displayName}
|
||||
/>
|
||||
)}
|
||||
@@ -259,7 +265,7 @@ function HistoryDetailModal({ config }) {
|
||||
{showUpdatedAt && (
|
||||
<DetailRow
|
||||
icon={<Update sx={{ fontSize: 16 }} />}
|
||||
label='Last updated'
|
||||
label={t('detail.lastUpdated')}
|
||||
value={fmt.dateTime(entry.updatedAt)}
|
||||
/>
|
||||
)}
|
||||
@@ -268,7 +274,7 @@ function HistoryDetailModal({ config }) {
|
||||
{entry.duration > 0 && (
|
||||
<DetailRow
|
||||
icon={<Schedule sx={{ fontSize: 16 }} />}
|
||||
label='Duration'
|
||||
label={t('detail.duration')}
|
||||
value={formatDuration(entry.duration)}
|
||||
/>
|
||||
)}
|
||||
@@ -277,8 +283,8 @@ function HistoryDetailModal({ config }) {
|
||||
{entry.points > 0 && (
|
||||
<DetailRow
|
||||
icon={<Typography sx={{ fontSize: 14 }}>★</Typography>}
|
||||
label='Points earned'
|
||||
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
|
||||
label={t('detail.pointsEarned')}
|
||||
value={t('detail.points', { count: entry.points })}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import FilterBuilderContent, {
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import { applyFilter } from '../../../utils/FilterEngine'
|
||||
import { useFilters } from '../../Filters/FilterQueries'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const EMPTY_FILTERS = []
|
||||
|
||||
@@ -25,6 +26,7 @@ const AdvancedFilterBuilder = ({
|
||||
userProfile = null,
|
||||
editingFilter = null,
|
||||
}) => {
|
||||
const { t } = useTranslation('filters')
|
||||
const [filterName, setFilterName] = useState('')
|
||||
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
||||
const [selections, setSelections] = useState(defaultSelections())
|
||||
@@ -76,15 +78,15 @@ const AdvancedFilterBuilder = ({
|
||||
|
||||
const handleSave = () => {
|
||||
if (!filterName.trim()) {
|
||||
setError('Please enter a filter name')
|
||||
setError(t('builder.errorName'))
|
||||
return
|
||||
}
|
||||
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
|
||||
setError('A filter with this name already exists')
|
||||
setError(t('nameExists'))
|
||||
return
|
||||
}
|
||||
if (conditions.length === 0) {
|
||||
setError('Please configure at least one filter condition')
|
||||
setError(t('builder.errorConditions'))
|
||||
return
|
||||
}
|
||||
onSave({
|
||||
@@ -106,7 +108,7 @@ const AdvancedFilterBuilder = ({
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
||||
{editingFilter ? t('builder.editTitle') : t('builder.newTitle')}
|
||||
{activeConditionCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary'>
|
||||
{activeConditionCount} condition
|
||||
@@ -129,17 +131,17 @@ const AdvancedFilterBuilder = ({
|
||||
{conditions.length > 0 ? (
|
||||
<>
|
||||
<Chip size='sm' variant='soft' color='neutral'>
|
||||
{previewCount} task{previewCount !== 1 ? 's' : ''}
|
||||
{t('tasks', { count: previewCount })}
|
||||
</Chip>
|
||||
{previewOverdueCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='danger'>
|
||||
{previewOverdueCount} overdue
|
||||
{t('overdue', { count: previewOverdueCount })}
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
Add conditions to preview
|
||||
{t('builder.addConditions')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -147,7 +149,7 @@ const AdvancedFilterBuilder = ({
|
||||
{/* Actions */}
|
||||
<ModalActions>
|
||||
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
@@ -155,7 +157,7 @@ const AdvancedFilterBuilder = ({
|
||||
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Filter
|
||||
{t('builder.save')}
|
||||
</Button>
|
||||
</ModalActions>
|
||||
</Box>
|
||||
@@ -168,10 +170,10 @@ const AdvancedFilterBuilder = ({
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
Filter Name
|
||||
{t('builder.name')}
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder='e.g. Overdue tasks for Alice'
|
||||
placeholder={t('builder.namePlaceholder')}
|
||||
value={filterName}
|
||||
onChange={e => {
|
||||
setFilterName(e.target.value)
|
||||
@@ -193,13 +195,13 @@ const AdvancedFilterBuilder = ({
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
|
||||
>
|
||||
Color
|
||||
{t('builder.color')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{FILTER_COLORS.map(c => (
|
||||
<Box
|
||||
key={c.value}
|
||||
title={c.name}
|
||||
title={t(`common:colors.${c.key}`)}
|
||||
onClick={() => setFilterColor(c.value)}
|
||||
sx={{
|
||||
width: 26,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Download } from '@mui/icons-material'
|
||||
import { Box, CircularProgress, Typography } from '@mui/joy'
|
||||
@@ -29,6 +30,7 @@ const downloadUrl = (url, fileName) => {
|
||||
}
|
||||
|
||||
function AttachmentViewerModal({ config }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [imgLoaded, setImgLoaded] = useState(false)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
@@ -49,7 +51,7 @@ function AttachmentViewerModal({ config }) {
|
||||
maxHeight='92vh'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
secondary={{ label: t('close'), onClick: handleClose }}
|
||||
primary={{
|
||||
label: 'Download',
|
||||
startDecorator: <Download />,
|
||||
@@ -73,7 +75,7 @@ function AttachmentViewerModal({ config }) {
|
||||
)}
|
||||
{imgError ? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Failed to load image.
|
||||
{t('imageLoadFailed')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
|
||||
|
||||
function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
const { t } = useTranslation('settings')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
@@ -66,7 +68,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
if (!encryptionKey.trim()) {
|
||||
setError('Encryption key is required')
|
||||
setError(t('backup.keyRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,7 +97,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Backup created and downloaded successfully',
|
||||
message: t('backup.created'),
|
||||
})
|
||||
|
||||
handleClose()
|
||||
@@ -104,7 +106,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
setError(errorData.message || 'Failed to create backup')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to create backup')
|
||||
setError(t('backup.createFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -120,12 +122,12 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!restoreEncryptionKey.trim()) {
|
||||
setError('Encryption key is required')
|
||||
setError(t('backup.keyRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!backupFile) {
|
||||
setError('Please select a backup file')
|
||||
setError(t('backup.selectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -142,7 +144,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
if (response.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Backup restored successfully. Please refresh the page.',
|
||||
message: t('backup.restored'),
|
||||
})
|
||||
|
||||
// Refresh the page after a short delay to allow user to see the message
|
||||
@@ -156,20 +158,20 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
setError(errorData.message || 'Failed to restore backup')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to restore backup')
|
||||
setError(t('backup.restoreFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
setError('Failed to read backup file')
|
||||
setError(t('backup.readFailed'))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
reader.readAsText(backupFile)
|
||||
} catch (err) {
|
||||
setError('Failed to restore backup')
|
||||
setError(t('backup.restoreFailed'))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -198,8 +200,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
const renderBackupTab = () => (
|
||||
<Box>
|
||||
<Typography level='body-md' mb={3}>
|
||||
Create an encrypted backup of your data. This backup will include all
|
||||
your chores, history, settings, and optionally your uploaded files.
|
||||
{t('backup.createIntro')}
|
||||
</Typography>
|
||||
|
||||
<FormControl sx={{ mb: 2 }}>
|
||||
@@ -208,7 +209,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
type='password'
|
||||
value={encryptionKey}
|
||||
onChange={e => setEncryptionKey(e.target.value)}
|
||||
placeholder='Enter a strong encryption key'
|
||||
placeholder={t('backup.keyPlaceholder')}
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Keep this key safe—you'll need it to restore your backup
|
||||
@@ -228,7 +229,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
<Checkbox
|
||||
checked={includeAssets}
|
||||
onChange={e => setIncludeAssets(e.target.checked)}
|
||||
label='Include uploaded files and assets'
|
||||
label={t('backup.includeAssets')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -243,8 +244,8 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
const renderRestoreTab = () => (
|
||||
<Box>
|
||||
<Typography level='body-md' mb={3} color='warning'>
|
||||
<strong>Warning:</strong> Restoring a backup will replace all your
|
||||
current data. This action cannot be undone.
|
||||
<strong>{t('backup.warningLabel')}</strong>{' '}
|
||||
{t('backup.restoreWarning')}
|
||||
</Typography>
|
||||
|
||||
<FormControl sx={{ mb: 2 }}>
|
||||
@@ -268,7 +269,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
type='password'
|
||||
value={restoreEncryptionKey}
|
||||
onChange={e => setRestoreEncryptionKey(e.target.value)}
|
||||
placeholder='Enter the encryption key used for this backup'
|
||||
placeholder={t('backup.restoreKeyPlaceholder')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -293,7 +294,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
label: t('accountSettings.cancel'),
|
||||
onClick: handleClose,
|
||||
disabled: loading,
|
||||
}}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
const { t } = useTranslation('settings')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [childName, setChildName] = useState('')
|
||||
@@ -19,39 +21,38 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
|
||||
if (touched.childName) {
|
||||
if (!childName.trim()) {
|
||||
newErrors.childName = 'Sub account name is required'
|
||||
newErrors.childName = t('childUsers.errNameRequired')
|
||||
} else if (childName.length < 2) {
|
||||
newErrors.childName = 'Sub account name must be at least 2 characters'
|
||||
newErrors.childName = t('childUsers.errNameMin')
|
||||
} else if (childName.length > 20) {
|
||||
newErrors.childName = 'Sub account name must be less than 20 characters'
|
||||
newErrors.childName = t('childUsers.errNameMax')
|
||||
} else if (!/^[a-z.-]+$/.test(childName)) {
|
||||
newErrors.childName =
|
||||
'Sub account name can only contain lowercase letters, dot and dash'
|
||||
newErrors.childName = t('childUsers.errNameChars')
|
||||
}
|
||||
}
|
||||
|
||||
if (touched.password) {
|
||||
if (!password) {
|
||||
newErrors.password = 'Password is required'
|
||||
newErrors.password = t('childUsers.errPasswordRequired')
|
||||
} else if (password.length < 8) {
|
||||
newErrors.password = 'Password must be between 8 and 64 characters'
|
||||
newErrors.password = t('childUsers.errPasswordLength')
|
||||
} else if (password.length > 64) {
|
||||
newErrors.password = 'Password must be between 8 and 64 characters'
|
||||
newErrors.password = t('childUsers.errPasswordLength')
|
||||
}
|
||||
}
|
||||
|
||||
if (touched.confirmPassword) {
|
||||
if (password !== confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match'
|
||||
newErrors.confirmPassword = t('childUsers.errPasswordMatch')
|
||||
}
|
||||
}
|
||||
|
||||
if (touched.displayName && displayName.length > 50) {
|
||||
newErrors.displayName = 'Display name must be less than 50 characters'
|
||||
newErrors.displayName = t('childUsers.errDisplayNameMax')
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
}, [childName, displayName, password, confirmPassword, touched])
|
||||
}, [childName, displayName, password, confirmPassword, touched, t])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setTouched({
|
||||
@@ -101,7 +102,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
title='Create Sub Account'
|
||||
title={t('childUsers.createTitle')}
|
||||
description='Create a login that can complete tasks assigned to this account.'
|
||||
size='md'
|
||||
closeOnBackdrop={!isSubmitting}
|
||||
@@ -109,12 +110,12 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
label: t('accountSettings.cancel'),
|
||||
onClick: handleClose,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Create Account',
|
||||
label: t('childUsers.createButton'),
|
||||
onClick: handleSubmit,
|
||||
disabled: !isValid || isSubmitting,
|
||||
loading: isSubmitting,
|
||||
@@ -124,14 +125,14 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
>
|
||||
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
|
||||
<Typography level='body2' mb={1}>
|
||||
Sub Account Name *
|
||||
{t('childUsers.nameLabel')}
|
||||
</Typography>
|
||||
<Input
|
||||
required
|
||||
fullWidth
|
||||
id='childName'
|
||||
name='childName'
|
||||
placeholder='Enter sub account name (e.g., sarah)'
|
||||
placeholder={t('childUsers.namePlaceholder')}
|
||||
value={childName}
|
||||
onChange={e => {
|
||||
setChildName(e.target.value)
|
||||
@@ -145,13 +146,13 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
|
||||
<FormControl error={!!errors.displayName} sx={{ mb: 2 }}>
|
||||
<Typography level='body2' mb={1}>
|
||||
Display Name
|
||||
{t('childUsers.displayNameLabel')}
|
||||
</Typography>
|
||||
<Input
|
||||
fullWidth
|
||||
id='displayName'
|
||||
name='displayName'
|
||||
placeholder='Display name (optional, defaults to sub account name)'
|
||||
placeholder={t('childUsers.displayNamePlaceholder')}
|
||||
value={displayName}
|
||||
onChange={e => {
|
||||
setDisplayName(e.target.value)
|
||||
@@ -165,7 +166,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
|
||||
<FormControl error={!!errors.password} sx={{ mb: 2 }}>
|
||||
<Typography level='body2' mb={1}>
|
||||
Password *
|
||||
{t('childUsers.passwordLabel')}
|
||||
</Typography>
|
||||
<Input
|
||||
required
|
||||
@@ -173,7 +174,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
name='password'
|
||||
type='password'
|
||||
id='password'
|
||||
placeholder='Enter password (8-64 characters)'
|
||||
placeholder={t('childUsers.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value)
|
||||
@@ -185,7 +186,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
|
||||
<FormControl error={!!errors.confirmPassword} sx={{ mb: 3 }}>
|
||||
<Typography level='body2' mb={1}>
|
||||
Confirm Password *
|
||||
{t('childUsers.confirmPasswordLabel')}
|
||||
</Typography>
|
||||
<Input
|
||||
required
|
||||
@@ -193,7 +194,7 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
name='confirmPassword'
|
||||
type='password'
|
||||
id='confirmPassword'
|
||||
placeholder='Confirm password'
|
||||
placeholder={t('childUsers.confirmPasswordPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={e => {
|
||||
setConfirmPassword(e.target.value)
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
const { t } = useTranslation('things')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [name, setName] = useState(currentThing?.name || '')
|
||||
@@ -33,17 +35,17 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
const isValid = () => {
|
||||
const newErrors = {}
|
||||
if (!name || name.trim() === '') {
|
||||
newErrors.name = 'Name is required'
|
||||
newErrors.name = t('errName')
|
||||
}
|
||||
|
||||
if (type === 'number' && isNaN(state)) {
|
||||
newErrors.state = 'State must be a number'
|
||||
newErrors.state = t('errStateNumber')
|
||||
}
|
||||
if (type === 'boolean' && !['true', 'false'].includes(state)) {
|
||||
newErrors.state = 'State must be true or false'
|
||||
newErrors.state = t('errStateBool')
|
||||
}
|
||||
if ((type === 'text' && !state) || state.trim() === '') {
|
||||
newErrors.state = 'State is required'
|
||||
newErrors.state = t('errStateRequired')
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
@@ -67,7 +69,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
label: t('common:cancel'),
|
||||
onClick: onClose,
|
||||
}}
|
||||
primary={{
|
||||
@@ -78,9 +80,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Typography>Name</Typography>
|
||||
<Typography>{t('name')}</Typography>
|
||||
<Textarea
|
||||
placeholder='Thing name'
|
||||
placeholder={t('namePlaceholder')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
sx={{ minWidth: 300 }}
|
||||
@@ -101,9 +103,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
</FormControl>
|
||||
{type === 'text' && (
|
||||
<FormControl>
|
||||
<Typography>Value</Typography>
|
||||
<Typography>{t('value')}</Typography>
|
||||
<Input
|
||||
placeholder='Thing value'
|
||||
placeholder={t('valuePlaceholder')}
|
||||
value={state || ''}
|
||||
onChange={e => setState(e.target.value)}
|
||||
sx={{ minWidth: 300 }}
|
||||
@@ -113,9 +115,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
)}
|
||||
{type === 'number' && (
|
||||
<FormControl>
|
||||
<Typography>Value</Typography>
|
||||
<Typography>{t('value')}</Typography>
|
||||
<Input
|
||||
placeholder='Thing value'
|
||||
placeholder={t('valuePlaceholder')}
|
||||
type='number'
|
||||
value={state || ''}
|
||||
onChange={e => {
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Input } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function DateModal({ isOpen, onClose, onSave, current, title }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [date, setDate] = useState(
|
||||
current ? new Date(current).toISOString().split('T')[0] : '',
|
||||
@@ -22,8 +24,8 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
|
||||
title={title}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
|
||||
secondary={{ label: t('cancel'), onClick: onClose }}
|
||||
primary={{ label: t('save'), onClick: handleSave, disabled: !date }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
const { t } = useTranslation('things')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [state, setState] = useState(currentThing?.state || '')
|
||||
@@ -13,7 +15,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
const newErrors = {}
|
||||
|
||||
if (state.trim() === '') {
|
||||
newErrors.state = 'State is required'
|
||||
newErrors.state = t('errStateRequired')
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
@@ -38,18 +40,18 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='sm'
|
||||
title='Update state'
|
||||
title={t('updateState')}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: 'Update', onClick: handleSave }}
|
||||
secondary={{ label: t('common:cancel'), onClick: onClose }}
|
||||
primary={{ label: t('update'), onClick: handleSave }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Typography>Value</Typography>
|
||||
<Typography>{t('value')}</Typography>
|
||||
<Input
|
||||
placeholder='Thing value'
|
||||
placeholder={t('valuePlaceholder')}
|
||||
value={state || ''}
|
||||
onChange={e => setState(e.target.value)}
|
||||
sx={{ minWidth: 300 }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
|
||||
import PROJECT_ICONS from '../../../utils/ProjectIcons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const IconPickerModal = ({
|
||||
isOpen,
|
||||
@@ -11,6 +12,7 @@ const IconPickerModal = ({
|
||||
currentIcon,
|
||||
projectColor,
|
||||
}) => {
|
||||
const { t } = useTranslation('projects')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const handleIconClick = iconValue => {
|
||||
@@ -25,13 +27,13 @@ const IconPickerModal = ({
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
unmountDelay={250}
|
||||
title='Choose Project Icon'
|
||||
title={t('iconPicker.chooseIcon')}
|
||||
footer={
|
||||
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
|
||||
<ModalActions secondary={{ label: t('common:cancel'), onClick: onClose }} />
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<FormLabel>Available Icons</FormLabel>
|
||||
<FormLabel>{t('iconPicker.availableIcons')}</FormLabel>
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
@@ -85,7 +87,7 @@ const IconPickerModal = ({
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{iconData.name}
|
||||
{t(`icons.${iconData.key}`)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function PasswordChangeModal({ isOpen, onClose }) {
|
||||
const { t } = useTranslation('settings')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
@@ -17,13 +19,13 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match')
|
||||
} else if (password.length < 8) {
|
||||
setPasswordError('Password must be at least 8 characters')
|
||||
setPasswordError(t('passwordChange.minLength'))
|
||||
} else if (password.length > 64) {
|
||||
setPasswordError('Password must be less than 64 characters')
|
||||
setPasswordError(t('passwordChange.maxLength'))
|
||||
} else {
|
||||
setPasswordError(null)
|
||||
}
|
||||
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched])
|
||||
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched, t])
|
||||
|
||||
const handleAction = isConfirmed => onClose(isConfirmed ? password : null)
|
||||
const canSubmit =
|
||||
@@ -38,13 +40,13 @@ function PasswordChangeModal({ isOpen, onClose }) {
|
||||
open={isOpen}
|
||||
onClose={() => handleAction(false)}
|
||||
size='sm'
|
||||
title='Change Password'
|
||||
title={t('accountSettings.changePassword')}
|
||||
description='Choose a password between 8 and 64 characters.'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: () => handleAction(false) }}
|
||||
secondary={{ label: t('accountSettings.cancel'), onClick: () => handleAction(false) }}
|
||||
primary={{
|
||||
label: 'Change Password',
|
||||
label: t('accountSettings.changePassword'),
|
||||
disabled: !canSubmit,
|
||||
onClick: () => handleAction(true),
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Textarea } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function TextModal({
|
||||
isOpen,
|
||||
@@ -12,6 +13,7 @@ function TextModal({
|
||||
okText,
|
||||
cancelText,
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [text, setText] = useState(current)
|
||||
|
||||
@@ -35,7 +37,7 @@ function TextModal({
|
||||
>
|
||||
<Textarea
|
||||
autoFocus
|
||||
placeholder='Type in here…'
|
||||
placeholder={t('typeHere')}
|
||||
value={text}
|
||||
onChange={event => setText(event.target.value)}
|
||||
minRows={3}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocalization } from '../../../contexts/LocalizationContext'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import ConfirmationModal from './ConfirmationModal'
|
||||
|
||||
const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
const { t } = useTranslation('timer')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { fmt } = useLocalization()
|
||||
|
||||
@@ -181,8 +183,8 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccess({
|
||||
title: 'Session updated',
|
||||
message: 'Timer session has been updated successfully.',
|
||||
title: t('toast.sessionUpdatedTitle'),
|
||||
message: t('toast.sessionUpdatedMessage'),
|
||||
})
|
||||
refetchTimer()
|
||||
cancelEditingSession(sessionId)
|
||||
@@ -190,15 +192,15 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
},
|
||||
onError: () => {
|
||||
showError({
|
||||
title: 'Failed to update session',
|
||||
message: 'Please try again.',
|
||||
title: t('toast.sessionUpdateFailTitle'),
|
||||
message: t('toast.tryAgain'),
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Error updating session',
|
||||
title: t('toast.sessionUpdateErrorTitle'),
|
||||
message: error.message,
|
||||
})
|
||||
} finally {
|
||||
@@ -213,15 +215,15 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccess({
|
||||
title: 'Session deleted',
|
||||
message: 'Timer session has been deleted successfully.',
|
||||
title: t('toast.sessionDeletedTitle'),
|
||||
message: t('toast.sessionDeletedMessage'),
|
||||
})
|
||||
refetchTimer()
|
||||
onTimerUpdate?.()
|
||||
},
|
||||
onError: error => {
|
||||
showError({
|
||||
title: 'Error deleting session',
|
||||
title: t('toast.sessionDeleteErrorTitle'),
|
||||
message: error.message,
|
||||
})
|
||||
},
|
||||
@@ -235,10 +237,10 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
const confirmDeleteSession = sessionId => {
|
||||
setConfirmDeleteConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Timer Session',
|
||||
message: 'Are you sure you want to delete this timer session?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
title: t('toast.deleteConfirmTitle'),
|
||||
message: t('toast.deleteConfirmMessage'),
|
||||
confirmText: t('common:delete'),
|
||||
cancelText: t('common:cancel'),
|
||||
color: 'danger',
|
||||
onClose: isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
@@ -310,23 +312,23 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
tertiary={
|
||||
!loading && timerData && !editingSessions[timerData.id]
|
||||
? {
|
||||
label: 'Delete',
|
||||
label: t('common:delete'),
|
||||
color: 'danger',
|
||||
onClick: () => confirmDeleteSession(timerData.id),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
secondary={{ label: t('common:close'), onClick: handleClose }}
|
||||
primary={
|
||||
!loading && timerData
|
||||
? editingSessions[timerData.id]
|
||||
? {
|
||||
label: 'Save',
|
||||
label: t('common:save'),
|
||||
onClick: () => saveSession(timerData.id),
|
||||
loading,
|
||||
}
|
||||
: {
|
||||
label: 'Edit',
|
||||
label: t('common:edit'),
|
||||
startDecorator: <Edit />,
|
||||
onClick: () => startEditingSession(),
|
||||
}
|
||||
@@ -337,13 +339,13 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
>
|
||||
{loading && (
|
||||
<Alert color='neutral' sx={{ mb: 2 }}>
|
||||
Loading timer data...
|
||||
{t('loading')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !timerData && (
|
||||
<Alert color='warning' sx={{ mb: 2 }}>
|
||||
No timer data found for this chore.
|
||||
{t('noData')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -403,7 +405,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Active Work
|
||||
{t('activeWork')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -457,7 +459,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Break Time
|
||||
{t('breakTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -511,7 +513,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Work Sessions
|
||||
{t('workSessions')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -565,7 +567,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Total Time
|
||||
{t('totalTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -597,12 +599,12 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary', fontWeight: 'medium' }}
|
||||
>
|
||||
Work vs Break Distribution
|
||||
{t('distribution')}
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{calculateCurrentActiveDuration() > 0
|
||||
? `${Math.round((calculateCurrentActiveDuration() / calculateTotalDuration()) * 100)}% active`
|
||||
: 'No active time yet'}
|
||||
: t('noActiveTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -630,7 +632,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
{/* Time Session */}
|
||||
<Box>
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Session Breakdown
|
||||
{t('sessionBreakdown')}
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
@@ -644,7 +646,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
level='body-sm'
|
||||
sx={{ fontWeight: 'bold', mb: 2 }}
|
||||
>
|
||||
Work Sessions ({timerData.pauseLog.length})
|
||||
{t('workSessions')} ({timerData.pauseLog.length})
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
@@ -795,7 +797,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
level='body-sm'
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Sessions
|
||||
{t('sessions')}
|
||||
</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -803,7 +805,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
startDecorator={<Add />}
|
||||
onClick={() => addPauseLogEntry(timerData.id)}
|
||||
>
|
||||
Add Session
|
||||
{t('addSession')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -855,7 +857,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
level='body-xs'
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Start Time
|
||||
{t('startTime')}
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
@@ -878,7 +880,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
level='body-xs'
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
End Time
|
||||
{t('endTime')}
|
||||
</Typography>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
@@ -903,7 +905,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Leave empty if session is ongoing
|
||||
{t('leaveEmpty')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
@@ -941,7 +943,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
|
||||
{!timerData && (
|
||||
<Alert color='neutral' sx={{ mt: 2 }}>
|
||||
No timer session found for this chore.
|
||||
{t('noSessionForChore')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -14,8 +14,10 @@ import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function UserDeletionModal({ isOpen, onClose }) {
|
||||
const { t } = useTranslation('settings')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const Navigate = useNavigate()
|
||||
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
|
||||
@@ -47,7 +49,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
|
||||
const checkDeletionRequirements = async () => {
|
||||
if (password.trim() === '') {
|
||||
setError('Please enter your password to continue')
|
||||
setError(t('deletion.passwordRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,7 +99,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
|
||||
const executeUserDeletion = async () => {
|
||||
if (password.trim() === '' || confirmation !== 'DELETE') {
|
||||
setError('Please enter your password and type DELETE to confirm')
|
||||
setError(t('deletion.passwordAndDelete'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +122,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete account:', err)
|
||||
setError('Failed to delete account')
|
||||
setError(t('deletion.failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -181,7 +183,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder='Enter your password'
|
||||
placeholder={t('deletion.passwordPlaceholder')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -196,7 +198,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
const renderTransferStep = () => (
|
||||
<>
|
||||
<Typography level='body-md' mb={3}>
|
||||
You own circles that require ownership transfer before deletion. Please
|
||||
{t('deletion.transferIntro')}
|
||||
select new owners:
|
||||
</Typography>
|
||||
|
||||
@@ -208,7 +210,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
<FormControl>
|
||||
<FormLabel>New Owner</FormLabel>
|
||||
<Select
|
||||
placeholder='Select new owner'
|
||||
placeholder={t('deletion.selectOwner')}
|
||||
value={
|
||||
transferOptions.find(t => t.circleId === circle.id)
|
||||
?.newOwnerId || ''
|
||||
@@ -237,8 +239,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
const renderConfirmationStep = () => (
|
||||
<>
|
||||
<Typography level='body-md' mb={3}>
|
||||
Please enter your password and type <strong>DELETE</strong> to confirm
|
||||
account deletion.
|
||||
{t('deletion.confirmPrompt')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={2}>
|
||||
on successful deletion, you will be logged out and redirected to the
|
||||
@@ -251,7 +252,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder='Enter your password'
|
||||
placeholder={t('deletion.passwordPlaceholder')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -260,7 +261,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
<Input
|
||||
value={confirmation}
|
||||
onChange={e => setConfirmation(e.target.value)}
|
||||
placeholder='DELETE'
|
||||
placeholder={t('deletion.typeDelete')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -304,7 +305,7 @@ function UserDeletionModal({ isOpen, onClose }) {
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
label: t('accountSettings.cancel'),
|
||||
onClick: () => handleClose(false),
|
||||
}}
|
||||
primary={{
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Avatar, Box, List, ListItem, Typography } from '@mui/joy'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
return (
|
||||
@@ -13,7 +15,7 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||
fullWidth={true}
|
||||
title='Select User'
|
||||
footer={
|
||||
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
|
||||
<ModalActions secondary={{ label: t('cancel'), onClick: onClose }} />
|
||||
}
|
||||
>
|
||||
<List sx={{ mb: 2 }}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions.jsx'
|
||||
@@ -18,6 +19,7 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
|
||||
function RedeemPointsModal({ config }) {
|
||||
const { t } = useTranslation('points')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [points, setPoints] = useState(0)
|
||||
@@ -56,12 +58,12 @@ function RedeemPointsModal({ config }) {
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
size='md'
|
||||
title='Redeem Points'
|
||||
title={t('redeem')}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: config?.onClose }}
|
||||
secondary={{ label: t('common:cancel'), onClick: config?.onClose }}
|
||||
primary={{
|
||||
label: 'Redeem',
|
||||
label: t('redeemModal.redeemButton'),
|
||||
startDecorator: <CreditCard />,
|
||||
disabled: !canRedeem,
|
||||
onClick: () =>
|
||||
@@ -103,7 +105,7 @@ function RedeemPointsModal({ config }) {
|
||||
startDecorator={<Toll />}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
{config?.available || 0} points available
|
||||
{t('redeemModal.pointsAvailable', { count: config?.available || 0 })}
|
||||
</Chip>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -112,7 +114,7 @@ function RedeemPointsModal({ config }) {
|
||||
{/* Points Input Section */}
|
||||
<FormControl>
|
||||
<FormLabel sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Points to Redeem
|
||||
{t('redeemModal.pointsToRedeem')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
type='number'
|
||||
@@ -124,7 +126,7 @@ function RedeemPointsModal({ config }) {
|
||||
input: {
|
||||
min: 0,
|
||||
max: config?.available || 0,
|
||||
placeholder: 'Enter points...',
|
||||
placeholder: t('redeemModal.placeholder'),
|
||||
},
|
||||
}}
|
||||
onChange={e => handlePointsChange(e.target.value)}
|
||||
@@ -140,7 +142,7 @@ function RedeemPointsModal({ config }) {
|
||||
/>
|
||||
{points > config?.available && (
|
||||
<Typography level='body-xs' sx={{ color: 'danger.500', mt: 0.5 }}>
|
||||
Cannot exceed available points
|
||||
{t('redeemModal.cannotExceed')}
|
||||
</Typography>
|
||||
)}
|
||||
</FormControl>
|
||||
@@ -148,7 +150,7 @@ function RedeemPointsModal({ config }) {
|
||||
{/* Quick Selection Buttons */}
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
Quick Add:
|
||||
{t('redeemModal.quickAdd')}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction='row'
|
||||
@@ -199,19 +201,21 @@ function RedeemPointsModal({ config }) {
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
You are about to redeem
|
||||
{t('redeemModal.aboutToRedeem')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='h4'
|
||||
sx={{ color: 'primary.600', fontWeight: 700 }}
|
||||
>
|
||||
{points} points
|
||||
{t('redeemModal.amount', { count: points })}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary', mt: 0.5 }}
|
||||
>
|
||||
Remaining: {(config?.available || 0) - points} points
|
||||
{t('redeemModal.remaining', {
|
||||
count: (config?.available || 0) - points,
|
||||
})}
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Box, Container, Sheet, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
const PaymentCancelledView = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,10 +39,10 @@ const PaymentCancelledView = () => {
|
||||
>
|
||||
<Logo />
|
||||
<Typography level='h2' sx={{ mt: 2, mb: 1 }}>
|
||||
Payment has been cancelled
|
||||
{t('payment.cancelledTitle')}
|
||||
</Typography>
|
||||
<Typography level='body-md' sx={{ mb: 2 }}>
|
||||
You will be redirected to the main page shortly.
|
||||
{t('payment.cancelledRedirect')}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
</Box>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Box, Container, Sheet, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
const PaymentSuccessView = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,10 +39,10 @@ const PaymentSuccessView = () => {
|
||||
>
|
||||
<Logo />
|
||||
<Typography level='h2' sx={{ mt: 2, mb: 1 }}>
|
||||
Payment Successful!
|
||||
{t('payment.successTitle')}
|
||||
</Typography>
|
||||
<Typography level='body-md' sx={{ mb: 2 }}>
|
||||
You will be redirected to the settings page shortly.
|
||||
{t('payment.successRedirect')}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
</Box>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ProjectModal from '../Modals/Inputs/ProjectModal'
|
||||
|
||||
@@ -40,6 +41,7 @@ const ProjectCardContent = ({
|
||||
onCardClick,
|
||||
onToggleActions,
|
||||
}) => {
|
||||
const { t } = useTranslation('projects')
|
||||
// Check if current user owns this project
|
||||
const isOwnedByCurrentUser = project.created_by === currentUserId
|
||||
const isDefaultProject = project.id === 'default'
|
||||
@@ -144,7 +146,7 @@ const ProjectCardContent = ({
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
Default
|
||||
{t('defaultChip')}
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
@@ -178,7 +180,7 @@ const ProjectCardContent = ({
|
||||
color: 'primary.500',
|
||||
}}
|
||||
>
|
||||
{taskCount} tasks
|
||||
{t('tasks', { count: taskCount })}
|
||||
</Chip>
|
||||
|
||||
{!isOwnedByCurrentUser && !isDefaultProject && (
|
||||
@@ -193,7 +195,7 @@ const ProjectCardContent = ({
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
{t('shared')}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
@@ -218,6 +220,7 @@ const ProjectCardContent = ({
|
||||
}
|
||||
|
||||
const ProjectView = () => {
|
||||
const { t } = useTranslation('projects')
|
||||
const { data: projects, isProjectsLoading, isError } = useProjects()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
||||
@@ -250,11 +253,11 @@ const ProjectView = () => {
|
||||
const project = userProjects.find(p => p.id === id)
|
||||
setConfirmationModel({
|
||||
isOpen: true,
|
||||
title: 'Delete Project',
|
||||
message: `Are you sure you want to delete "${project?.name}"? This will remove the project but keep all tasks (they'll move to the Default Project).`,
|
||||
confirmText: 'Delete',
|
||||
title: t('delete.title'),
|
||||
message: t('delete.message', { name: project?.name }),
|
||||
confirmText: t('common:delete'),
|
||||
color: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
cancelText: t('common:cancel'),
|
||||
onClose: confirmed => {
|
||||
if (confirmed === true) {
|
||||
handleDeleteProject(id)
|
||||
@@ -347,7 +350,7 @@ const ProjectView = () => {
|
||||
if (isError) {
|
||||
return (
|
||||
<Typography color='danger' textAlign='center'>
|
||||
Failed to load projects. Please try again.
|
||||
{t('loadError')}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
@@ -360,11 +363,10 @@ const ProjectView = () => {
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Projects
|
||||
{t('common:navigation.projects')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Organize your tasks into projects. Create custom workspaces to keep
|
||||
your tasks organized and easily accessible.
|
||||
{t('blurb')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -378,8 +380,8 @@ const ProjectView = () => {
|
||||
<ProjectCardContent
|
||||
project={{
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
description: 'All tasks without a specific project',
|
||||
name: t('chores:toolbar.defaultProject'),
|
||||
description: t('defaultDescription'),
|
||||
icon: 'FolderOpen',
|
||||
color: '#1976d2',
|
||||
created_by: userProfile?.id,
|
||||
@@ -389,7 +391,7 @@ const ProjectView = () => {
|
||||
onCardClick={() =>
|
||||
handleCardClick({
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
name: t('chores:toolbar.defaultProject'),
|
||||
icon: 'FolderOpen',
|
||||
color: '#1976d2',
|
||||
})
|
||||
@@ -429,7 +431,7 @@ const ProjectView = () => {
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Edit
|
||||
{t('common:edit')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
@@ -450,7 +452,7 @@ const ProjectView = () => {
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Delete
|
||||
{t('common:delete')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Refresh, Star, Token } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
@@ -29,6 +30,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import PolicyUpdateModal from '../Modals/PolicyUpdateModal'
|
||||
|
||||
const DeveloperSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const queryClient = useQueryClient()
|
||||
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -240,7 +242,7 @@ const DeveloperSettings = () => {
|
||||
if (result.success) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Token refreshed successfully',
|
||||
message: t('developer.tokenRefreshed'),
|
||||
})
|
||||
|
||||
// Reload token expiry data
|
||||
@@ -276,7 +278,7 @@ const DeveloperSettings = () => {
|
||||
const data = await response.json()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Refresh token endpoint called successfully',
|
||||
message: t('developer.endpointCalled'),
|
||||
})
|
||||
|
||||
// Reload token expiry data
|
||||
@@ -345,7 +347,7 @@ const DeveloperSettings = () => {
|
||||
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Local offline database cleared. Starting full sync...',
|
||||
message: t('developer.clearDbCleared'),
|
||||
})
|
||||
|
||||
const didSync = await syncEngine.sync()
|
||||
@@ -353,7 +355,7 @@ const DeveloperSettings = () => {
|
||||
await queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Full sync completed from the beginning',
|
||||
message: t('developer.clearDbDone'),
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
@@ -450,7 +452,7 @@ const DeveloperSettings = () => {
|
||||
<Typography level='h3'>Developer Settings</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
View technical information about your authentication tokens and session
|
||||
{t('developer.intro')}
|
||||
state. This information is useful for debugging and development
|
||||
purposes.
|
||||
</Typography>
|
||||
@@ -548,7 +550,7 @@ const DeveloperSettings = () => {
|
||||
</>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Refresh tokens are managed via HTTP-only cookies on web platform
|
||||
{t('developer.refreshCookieNote')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1152,7 +1154,7 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No reconnection information available
|
||||
{t('developer.noReconnectInfo')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1192,7 +1194,7 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No timeout information available
|
||||
{t('developer.noTimeoutInfo')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1237,7 +1239,7 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No debug information available
|
||||
{t('developer.noDebugInfo')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
import {
|
||||
AccountCircle,
|
||||
Api,
|
||||
BugReport,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Code,
|
||||
FamilyRestroom,
|
||||
Feedback,
|
||||
Language,
|
||||
Notifications,
|
||||
Palette,
|
||||
Person,
|
||||
PrivacyTip,
|
||||
Security,
|
||||
Settings,
|
||||
Star,
|
||||
Storage,
|
||||
ViewSidebar,
|
||||
} from '@mui/icons-material'
|
||||
import { BugReport, ChevronRight, Feedback, Star } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -38,6 +19,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { SETTINGS_SECTIONS } from '../../constants/settingsSections'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { isParentUser } from '../../utils/UserHelpers'
|
||||
@@ -52,91 +34,13 @@ const SettingsOverview = () => {
|
||||
const [bugReportOpen, setBugReportOpen] = useState(false)
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
id: 'profile',
|
||||
title: t('overview.sections.profile.title'),
|
||||
description: t('overview.sections.profile.description'),
|
||||
icon: <Person />,
|
||||
},
|
||||
{
|
||||
id: 'circle',
|
||||
title: t('overview.sections.circle.title'),
|
||||
description: t('overview.sections.circle.description'),
|
||||
icon: <Circle />,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
title: t('overview.sections.account.title'),
|
||||
description: t('overview.sections.account.description'),
|
||||
icon: <AccountCircle />,
|
||||
},
|
||||
{
|
||||
id: 'subaccounts',
|
||||
title: t('overview.sections.subaccounts.title'),
|
||||
description: t('overview.sections.subaccounts.description'),
|
||||
icon: <FamilyRestroom />,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
title: t('overview.sections.notifications.title'),
|
||||
description: t('overview.sections.notifications.description'),
|
||||
icon: <Notifications />,
|
||||
},
|
||||
{
|
||||
id: 'mfa',
|
||||
title: t('overview.sections.mfa.title'),
|
||||
description: t('overview.sections.mfa.description'),
|
||||
icon: <Security />,
|
||||
},
|
||||
{
|
||||
id: 'apitokens',
|
||||
title: t('overview.sections.apitokens.title'),
|
||||
description: t('overview.sections.apitokens.description'),
|
||||
icon: <Api />,
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
title: t('overview.sections.storage.title'),
|
||||
description: t('overview.sections.storage.description'),
|
||||
icon: <Storage />,
|
||||
},
|
||||
{
|
||||
id: 'sidepanel',
|
||||
title: t('overview.sections.sidepanel.title'),
|
||||
description: t('overview.sections.sidepanel.description'),
|
||||
icon: <ViewSidebar />,
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
title: t('overview.sections.theme.title'),
|
||||
description: t('overview.sections.theme.description'),
|
||||
icon: <Palette />,
|
||||
},
|
||||
{
|
||||
id: 'localization',
|
||||
title: t('overview.sections.localization.title'),
|
||||
description: t('overview.sections.localization.description'),
|
||||
icon: <Language />,
|
||||
isBeta: true,
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: t('overview.sections.advanced.title'),
|
||||
description: t('overview.sections.advanced.description'),
|
||||
icon: <Settings />,
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
title: t('overview.sections.privacy.title'),
|
||||
description: t('overview.sections.privacy.description'),
|
||||
icon: <PrivacyTip />,
|
||||
},
|
||||
{
|
||||
id: 'developer',
|
||||
title: t('overview.sections.developer.title'),
|
||||
description: t('overview.sections.developer.description'),
|
||||
icon: <Code />,
|
||||
},
|
||||
...SETTINGS_SECTIONS.map(({ icon: Icon, id, isBeta }) => ({
|
||||
id,
|
||||
title: t(`overview.sections.${id}.title`),
|
||||
description: t(`overview.sections.${id}.description`),
|
||||
icon: <Icon />,
|
||||
isBeta,
|
||||
})),
|
||||
{
|
||||
id: 'feedback',
|
||||
title: t('overview.sections.feedback.title'),
|
||||
@@ -161,23 +65,19 @@ const SettingsOverview = () => {
|
||||
navigate(`/settings/${setting.id}`)
|
||||
}
|
||||
|
||||
const parentOnlyIds = SETTINGS_SECTIONS.filter(
|
||||
section => section.parentOnly,
|
||||
).map(section => section.id)
|
||||
|
||||
// Filter settings based on user type
|
||||
const getAvailableSettings = () => {
|
||||
const parentOnlySettings = [
|
||||
'children',
|
||||
'mfa',
|
||||
'apitokens',
|
||||
'circle',
|
||||
'account',
|
||||
]
|
||||
|
||||
if (isParentUser(userProfile)) {
|
||||
// Parent users can access all settings
|
||||
return settingsCards
|
||||
} else {
|
||||
// Child users can only access basic settings
|
||||
return settingsCards.filter(
|
||||
setting => !parentOnlySettings.includes(setting.id),
|
||||
setting => !parentOnlyIds.includes(setting.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Card, IconButton, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SummaryCard = () => {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<Card>
|
||||
<div className='flex justify-between'>
|
||||
<div>
|
||||
<Typography level='h2'>Summary</Typography>
|
||||
<Typography level='body-xs'>
|
||||
This is a summary of your chores
|
||||
{t('summaryOfChores')}
|
||||
</Typography>
|
||||
</div>
|
||||
<IconButton>
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Add } from '@mui/icons-material'
|
||||
import { Divider, Menu, MenuItem } from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const AutocompleteDropdown = ({
|
||||
currentValue,
|
||||
onCreateSuggestion, // Called when the "Create new" row is chosen
|
||||
@@ -62,7 +60,6 @@ const AutocompleteDropdown = ({
|
||||
position: 'relative',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: Z_INDEX.MODAL_POPOVER,
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TrendingUp,
|
||||
Update,
|
||||
} from '@mui/icons-material'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -41,6 +42,7 @@ import { useThingHistory } from '../../queries/ThingQueries'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
const ThingsHistory = () => {
|
||||
const { t } = useTranslation('things')
|
||||
const { id } = useParams()
|
||||
const theme = useTheme()
|
||||
const { fmt } = useLocalization()
|
||||
@@ -186,7 +188,7 @@ const ThingsHistory = () => {
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Things Overview
|
||||
{t('history.overview')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -266,7 +268,7 @@ const ThingsHistory = () => {
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Data Visualization
|
||||
{t('history.visualization')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -328,7 +330,7 @@ const ThingsHistory = () => {
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Change History
|
||||
{t('history.changeHistory')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 'sm', p: 1, boxShadow: 'md' }}>
|
||||
@@ -379,7 +381,7 @@ const ThingsHistory = () => {
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
}}
|
||||
>
|
||||
Updated
|
||||
{t('history.updated')}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
|
||||
@@ -48,14 +48,21 @@ import { useLabels } from '../Labels/LabelQueries'
|
||||
import { ChoresGrouper } from '../../utils/Chores'
|
||||
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const groupByDate = history => {
|
||||
const aggregated = {}
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const item = history[i]
|
||||
const date = new Date(
|
||||
item.performedAt || item.updatedAt,
|
||||
).toLocaleDateString()
|
||||
// Key by a stable local ISO day (YYYY-MM-DD) so the render-time
|
||||
// formatter (fmt.date) receives a parseable date instead of a
|
||||
// locale-formatted string, which produced "Invalid date".
|
||||
const d = new Date(item.performedAt || item.updatedAt || item.createdAt)
|
||||
const date = isNaN(d.getTime())
|
||||
? 'unknown'
|
||||
: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate(),
|
||||
).padStart(2, '0')}`
|
||||
if (!aggregated[date]) {
|
||||
aggregated[date] = []
|
||||
}
|
||||
@@ -83,6 +90,7 @@ const ChoreHistoryItem = ({
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const { t } = useTranslation('history')
|
||||
const cfg = statusConfig[status] ?? statusConfig[1]
|
||||
|
||||
return (
|
||||
@@ -136,7 +144,7 @@ const ChoreHistoryItem = ({
|
||||
</Typography>
|
||||
{points && (
|
||||
<Chip size='sm' color='success' startDecorator={<Toll />}>
|
||||
{`${points} points`}
|
||||
{t('detail.points', { count: points })}
|
||||
</Chip>
|
||||
)}
|
||||
{notes && (
|
||||
@@ -151,7 +159,7 @@ const ChoreHistoryItem = ({
|
||||
onViewNote?.(notes)
|
||||
}}
|
||||
>
|
||||
Note
|
||||
{t('detail.note')}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
@@ -174,7 +182,7 @@ const ChoreHistoryTimeline = ({
|
||||
{Object.entries(groupedHistory).map(([date, items]) => (
|
||||
<Box key={date} sx={{ mb: 4 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 0.5 }}>
|
||||
{fmt.date(date)}
|
||||
{date === 'unknown' ? '—' : fmt.date(date)}
|
||||
</Typography>
|
||||
<Divider />
|
||||
<Stack spacing={1}>
|
||||
@@ -197,7 +205,7 @@ const ChoreHistoryTimeline = ({
|
||||
)
|
||||
}
|
||||
|
||||
const renderPieChart = (data, size, isPrimary, chartType = null) => {
|
||||
const renderPieChart = (t, data, size, isPrimary, chartType = null) => {
|
||||
// Filter out items with zero or negative values
|
||||
const validData = data.filter(item => item.value > 0)
|
||||
|
||||
@@ -216,7 +224,7 @@ const renderPieChart = (data, size, isPrimary, chartType = null) => {
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No data available
|
||||
{t('charts.noData')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
@@ -400,6 +408,7 @@ const USER_FILTER = (history, userId) => {
|
||||
}
|
||||
|
||||
const UserActivites = () => {
|
||||
const { t } = useTranslation('history')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const [tabValue, setTabValue] = React.useState(7)
|
||||
@@ -448,16 +457,16 @@ const UserActivites = () => {
|
||||
() => [
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
label: t('filter.status'),
|
||||
type: 'multi-select',
|
||||
icon: <Checklist />,
|
||||
options: [
|
||||
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
|
||||
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
|
||||
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
|
||||
{ value: 1, label: t('status.completed'), color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||
{ value: 2, label: t('status.skipped'), color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||
{ value: 3, label: t('filter.pending'), color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||
{ value: 4, label: t('status.rejected'), color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
|
||||
{ value: 5, label: t('status.missed'), color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
|
||||
{ value: 6, label: t('status.rescheduled'), color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
|
||||
],
|
||||
filterFn: (item, values) => values.includes(item.status),
|
||||
},
|
||||
@@ -465,7 +474,7 @@ const UserActivites = () => {
|
||||
? [
|
||||
{
|
||||
id: 'label',
|
||||
label: 'Labels',
|
||||
label: t('filter.labels'),
|
||||
type: 'multi-select',
|
||||
icon: <Style />,
|
||||
options: userLabels.map(l => ({
|
||||
@@ -492,20 +501,20 @@ const UserActivites = () => {
|
||||
: []),
|
||||
{
|
||||
id: 'hasNotes',
|
||||
label: 'Has Notes',
|
||||
label: t('filter.hasNotes'),
|
||||
type: 'boolean',
|
||||
icon: <EventNote />,
|
||||
filterFn: item => !!item.notes,
|
||||
},
|
||||
{
|
||||
id: 'hasPoints',
|
||||
label: 'Has Points',
|
||||
label: t('filter.hasPoints'),
|
||||
type: 'boolean',
|
||||
icon: <Toll />,
|
||||
filterFn: item => (item.points ?? 0) > 0,
|
||||
},
|
||||
],
|
||||
[userLabels],
|
||||
[userLabels, t],
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -520,20 +529,20 @@ const UserActivites = () => {
|
||||
() => [
|
||||
{
|
||||
id: 'timePeriod',
|
||||
label: 'Time Period',
|
||||
label: t('filter.timePeriod'),
|
||||
type: 'single-select',
|
||||
icon: <CalendarMonth />,
|
||||
defaultValue: 7,
|
||||
options: [
|
||||
{ value: 7, label: '7 Days' },
|
||||
{ value: 30, label: '30 Days' },
|
||||
{ value: 90, label: '90 Days' },
|
||||
{ value: 365, label: 'All Time' },
|
||||
{ value: 7, label: t('period.days', { count: 7 }) },
|
||||
{ value: 30, label: t('period.days', { count: 30 }) },
|
||||
{ value: 90, label: t('period.days', { count: 90 }) },
|
||||
{ value: 365, label: t('period.allTime') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'completedBy',
|
||||
label: 'User',
|
||||
label: t('filter.user'),
|
||||
type: 'single-select',
|
||||
icon: <Person />,
|
||||
options: circleUsers.map(u => ({
|
||||
@@ -544,7 +553,7 @@ const UserActivites = () => {
|
||||
},
|
||||
...clientFilterDefs,
|
||||
],
|
||||
[circleUsers, clientFilterDefs],
|
||||
[circleUsers, clientFilterDefs, t],
|
||||
)
|
||||
|
||||
// Merge server-driven and client-driven active filter states for the bar
|
||||
@@ -686,7 +695,7 @@ const UserActivites = () => {
|
||||
// Add unlabeled tasks if there are any
|
||||
if (unlabeledCount > 0) {
|
||||
result.push({
|
||||
label: 'No Labels',
|
||||
label: t('charts.noLabels'),
|
||||
value: unlabeledCount,
|
||||
color: TASK_COLOR.ANYTIME,
|
||||
id: 'unlabeled',
|
||||
@@ -709,7 +718,9 @@ const UserActivites = () => {
|
||||
const assignee = circleUsers.find(
|
||||
user => user.userId === chore.assignedTo,
|
||||
)
|
||||
const assigneeName = assignee ? assignee.displayName : 'Unassigned'
|
||||
const assigneeName = assignee
|
||||
? assignee.displayName
|
||||
: t('charts.unassigned')
|
||||
const assigneeId = chore.assignedTo || 'unassigned'
|
||||
|
||||
if (assigneeCounts[assigneeId]) {
|
||||
@@ -802,7 +813,7 @@ const UserActivites = () => {
|
||||
// Add unlabeled tasks duration if there is any
|
||||
if (unlabeledDuration > 0) {
|
||||
result.push({
|
||||
label: 'No Labels',
|
||||
label: t('charts.noLabels'),
|
||||
value: Math.round((unlabeledDuration / 3600) * 10) / 10, // Convert to hours and round to 1 decimal
|
||||
color: TASK_COLOR.ANYTIME,
|
||||
id: 'unlabeled',
|
||||
@@ -823,7 +834,7 @@ const UserActivites = () => {
|
||||
// Iterate through ChoreHistory to get actual time spent per task
|
||||
history.forEach(historyItem => {
|
||||
const duration = historyItem.duration || 0 // duration in seconds from ChoreHistory
|
||||
const taskName = historyItem.choreName || 'Unknown Task'
|
||||
const taskName = historyItem.choreName || t('charts.unknownTask')
|
||||
|
||||
if (taskDurations[taskName]) {
|
||||
taskDurations[taskName].duration += duration
|
||||
@@ -886,7 +897,7 @@ const UserActivites = () => {
|
||||
|
||||
if (totalCompleted > 0) {
|
||||
result.push({
|
||||
label: `On time`,
|
||||
label: t('badge.onTimeLabel'),
|
||||
value: totalCompleted,
|
||||
color: TASK_COLOR.COMPLETED,
|
||||
id: 1,
|
||||
@@ -895,7 +906,7 @@ const UserActivites = () => {
|
||||
|
||||
if (totalLate > 0) {
|
||||
result.push({
|
||||
label: `Late`,
|
||||
label: t('charts.late'),
|
||||
value: totalLate,
|
||||
color: TASK_COLOR.LATE,
|
||||
id: 2,
|
||||
@@ -904,7 +915,7 @@ const UserActivites = () => {
|
||||
|
||||
if (totalNoDueDate > 0) {
|
||||
result.push({
|
||||
label: `Completed`,
|
||||
label: t('status.completed'),
|
||||
value: totalNoDueDate,
|
||||
color: TASK_COLOR.ANYTIME,
|
||||
id: 3,
|
||||
@@ -919,43 +930,43 @@ const UserActivites = () => {
|
||||
const chartData = {
|
||||
history: {
|
||||
data: historyPieChartData || [],
|
||||
title: 'Status',
|
||||
description: 'Completed tasks status',
|
||||
title: t('charts.status.title'),
|
||||
description: t('charts.status.description'),
|
||||
},
|
||||
due: {
|
||||
data: choreDuePieChartData || [],
|
||||
title: 'Due Date',
|
||||
description: 'Current tasks due date',
|
||||
title: t('charts.due.title'),
|
||||
description: t('charts.due.description'),
|
||||
},
|
||||
// assigned: {
|
||||
// data: choresAssignedChartData,
|
||||
// title: 'Assigned to me',
|
||||
// title: t('chores:sort.assignedToMe'),
|
||||
// description: 'Tasks assigned to you vs others',
|
||||
// },
|
||||
priority: {
|
||||
data: choresPriorityChartData || [],
|
||||
title: 'Priority',
|
||||
description: 'Tasks by priority',
|
||||
title: t('charts.priority.title'),
|
||||
description: t('charts.priority.description'),
|
||||
},
|
||||
labels: {
|
||||
data: choresLabelsChartData || [],
|
||||
title: 'Labels',
|
||||
description: 'Tasks by labels',
|
||||
title: t('charts.labels.title'),
|
||||
description: t('charts.labels.description'),
|
||||
},
|
||||
labelsDuration: {
|
||||
data: choresLabelsDurationChartData || [],
|
||||
title: 'Labels (time)',
|
||||
description: 'Time spent by labels (hours)',
|
||||
title: t('charts.labelsDuration.title'),
|
||||
description: t('charts.labelsDuration.description'),
|
||||
},
|
||||
tasksTime: {
|
||||
data: tasksTimeChartData || [],
|
||||
title: 'Tasks (time)',
|
||||
description: 'Time spent by individual tasks (hours)',
|
||||
title: t('charts.tasksTime.title'),
|
||||
description: t('charts.tasksTime.description'),
|
||||
},
|
||||
assigneeBreakdown: {
|
||||
data: choresAssigneeBreakdownChartData || [],
|
||||
title: 'by Assignee',
|
||||
description: 'Tasks grouped by assignee',
|
||||
title: t('charts.assigneeBreakdown.title'),
|
||||
description: t('charts.assigneeBreakdown.description'),
|
||||
},
|
||||
}
|
||||
if (!userProfile) {
|
||||
@@ -976,7 +987,7 @@ const UserActivites = () => {
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Activities
|
||||
{t('activities.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -1025,7 +1036,7 @@ const UserActivites = () => {
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
isOpen: true,
|
||||
title: 'Note',
|
||||
title: t('detail.note'),
|
||||
content: notes,
|
||||
onClose: () => setNoteViewerConfig({ isOpen: false }),
|
||||
})
|
||||
@@ -1153,6 +1164,7 @@ const UserActivites = () => {
|
||||
}}
|
||||
>
|
||||
{renderPieChart(
|
||||
t,
|
||||
chartData[selectedChart].data,
|
||||
300, // Increased size for better chart container
|
||||
true,
|
||||
@@ -1216,7 +1228,7 @@ const UserActivites = () => {
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{renderPieChart(data, 70, false)}
|
||||
{renderPieChart(t, data, 70, false)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import moment from 'moment'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
|
||||
import { useChoresHistory } from '../../queries/ChoreQueries.jsx'
|
||||
@@ -48,6 +50,7 @@ import { RedeemPoints } from '../../utils/Fetcher.jsx'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
import RedeemPointsModal from '../Modals/RedeemPointsModal'
|
||||
const UserPoints = () => {
|
||||
const { t } = useTranslation('points')
|
||||
const [tabValue, setTabValue] = useState(7)
|
||||
const [isRedeemModalOpen, setIsRedeemModalOpen] = useState(false)
|
||||
const [leaderboardMode, setLeaderboardMode] = useState('points') // 'points' or 'tasks'
|
||||
@@ -104,15 +107,13 @@ const UserPoints = () => {
|
||||
const currentDate = new Date()
|
||||
currentDate.setDate(currentDate.getDate() - i)
|
||||
daysAggregated.push({
|
||||
label: currentDate.toLocaleString('en-US', { weekday: 'short' }),
|
||||
label: moment(currentDate).format('ddd'),
|
||||
points: 0,
|
||||
tasks: 0,
|
||||
})
|
||||
}
|
||||
history.forEach(chore => {
|
||||
const dayName = new Date(chore.performedAt).toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
})
|
||||
const dayName = moment(chore.performedAt).format('ddd')
|
||||
|
||||
const dayIndex = daysAggregated.findIndex(dayData => {
|
||||
if (userId)
|
||||
@@ -133,15 +134,13 @@ const UserPoints = () => {
|
||||
const currentDate = new Date()
|
||||
currentDate.setDate(currentDate.getDate() - i)
|
||||
daysAggregated.push({
|
||||
label: currentDate.toLocaleString('en-US', { day: 'numeric' }),
|
||||
label: moment(currentDate).format('D'),
|
||||
points: 0,
|
||||
tasks: 0,
|
||||
})
|
||||
}
|
||||
history.forEach(chore => {
|
||||
const dayName = new Date(chore.performedAt).toLocaleString('en-US', {
|
||||
day: 'numeric',
|
||||
})
|
||||
const dayName = moment(chore.performedAt).format('D')
|
||||
|
||||
const dayIndex = daysAggregated.findIndex(dayData => {
|
||||
if (userId)
|
||||
@@ -164,15 +163,13 @@ const UserPoints = () => {
|
||||
const currentMonth = new Date()
|
||||
currentMonth.setMonth(currentMonth.getMonth() - i)
|
||||
monthlyAggregated.push({
|
||||
label: currentMonth.toLocaleString('en-US', { month: 'short' }),
|
||||
label: moment(currentMonth).format('MMM'),
|
||||
points: 0,
|
||||
tasks: 0,
|
||||
})
|
||||
}
|
||||
history.forEach(chore => {
|
||||
const monthName = new Date(chore.performedAt).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
})
|
||||
const monthName = moment(chore.performedAt).format('MMM')
|
||||
|
||||
const monthIndex = monthlyAggregated.findIndex(monthData => {
|
||||
if (userId)
|
||||
@@ -195,15 +192,13 @@ const UserPoints = () => {
|
||||
const currentYear = new Date()
|
||||
currentYear.setFullYear(currentYear.getFullYear() - i)
|
||||
yearlyAggregated.push({
|
||||
label: currentYear.toLocaleString('en-US', { year: 'numeric' }),
|
||||
label: moment(currentYear).format('YYYY'),
|
||||
points: 0,
|
||||
tasks: 0,
|
||||
})
|
||||
}
|
||||
history.forEach(chore => {
|
||||
const yearName = new Date(chore.performedAt).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
})
|
||||
const yearName = moment(chore.performedAt).format('YYYY')
|
||||
|
||||
const yearIndex = yearlyAggregated.findIndex(yearData => {
|
||||
if (userId)
|
||||
@@ -305,14 +300,14 @@ const UserPoints = () => {
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
{leaderboardMode === 'points' ? 'Points' : 'Tasks'} Leaderboard
|
||||
{leaderboardMode === 'points'
|
||||
? t('leaderboard.titlePoints')
|
||||
: t('leaderboard.titleTasks')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Rankings based on{' '}
|
||||
{leaderboardMode === 'points'
|
||||
? 'points earned'
|
||||
: 'tasks completed'}{' '}
|
||||
during the selected time period
|
||||
? t('leaderboard.subtitlePoints')
|
||||
: t('leaderboard.subtitleTasks')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -358,9 +353,9 @@ const UserPoints = () => {
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: '7D', value: 7 },
|
||||
{ label: '6M', value: 6 * 30 },
|
||||
{ label: 'All', value: 24 * 30 },
|
||||
{ label: t('tabs.short7d'), value: 7 },
|
||||
{ label: t('tabs.short6m'), value: 6 * 30 },
|
||||
{ label: t('tabs.shortAll'), value: 24 * 30 },
|
||||
].map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
@@ -408,7 +403,7 @@ const UserPoints = () => {
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => setLeaderboardMode('points')}
|
||||
>
|
||||
Points
|
||||
{t('leaderboard.modePoints')}
|
||||
</Chip>
|
||||
<SwapHoriz
|
||||
sx={{ fontSize: '0.875rem', color: 'text.tertiary' }}
|
||||
@@ -420,7 +415,7 @@ const UserPoints = () => {
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => setLeaderboardMode('tasks')}
|
||||
>
|
||||
Tasks
|
||||
{t('leaderboard.modeTasks')}
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -512,7 +507,7 @@ const UserPoints = () => {
|
||||
color='primary'
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
You
|
||||
{t('leaderboard.you')}
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
@@ -520,8 +515,10 @@ const UserPoints = () => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
{user.periodTasks} tasks • {user.avgPointsPerTask} avg
|
||||
per task
|
||||
{t('leaderboard.tasksAndAvg', {
|
||||
tasks: user.periodTasks,
|
||||
avg: user.avgPointsPerTask,
|
||||
})}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -552,8 +549,12 @@ const UserPoints = () => {
|
||||
</Box>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{leaderboardMode === 'points'
|
||||
? `${user.availablePoints} available`
|
||||
: `${user.periodPoints} points`}
|
||||
? t('leaderboard.available', {
|
||||
count: user.availablePoints,
|
||||
})
|
||||
: t('leaderboard.points', {
|
||||
count: user.periodPoints,
|
||||
})}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -591,7 +592,7 @@ const UserPoints = () => {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Analytics sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
|
||||
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
|
||||
Filter & Analysis
|
||||
{t('filter.analysisTitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -610,7 +611,7 @@ const UserPoints = () => {
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
|
||||
Filter Points
|
||||
{t('filter.title')}
|
||||
</Typography>
|
||||
|
||||
<Stack
|
||||
@@ -621,7 +622,7 @@ const UserPoints = () => {
|
||||
{/* User Filter */}
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Show points for:
|
||||
{t('filter.showFor')}
|
||||
</Typography>
|
||||
<Select
|
||||
sx={{
|
||||
@@ -688,7 +689,7 @@ const UserPoints = () => {
|
||||
{/* Time Period Filter */}
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Time period:
|
||||
{t('filter.timePeriod')}
|
||||
</Typography>
|
||||
<Tabs
|
||||
onChange={(e, tabValue) => {
|
||||
@@ -713,9 +714,9 @@ const UserPoints = () => {
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: '7 Days', value: 7 },
|
||||
{ label: '6 Months', value: 6 * 30 },
|
||||
{ label: 'All Time', value: 24 * 30 },
|
||||
{ label: t('tabs.days7'), value: 7 },
|
||||
{ label: t('tabs.months6'), value: 6 * 30 },
|
||||
{ label: t('tabs.allTime'), value: 24 * 30 },
|
||||
].map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
@@ -759,7 +760,7 @@ const UserPoints = () => {
|
||||
}}
|
||||
sx={{ mt: 'auto' }}
|
||||
>
|
||||
Redeem Points
|
||||
{t('redeem')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -788,27 +789,32 @@ const UserPoints = () => {
|
||||
const pointsCards = [
|
||||
{
|
||||
icon: <Toll />,
|
||||
title: 'Available',
|
||||
text: `${availablePoints} points`,
|
||||
subtext: 'Ready to redeem',
|
||||
title: t('cards.available.title'),
|
||||
text: t('leaderboard.points', { count: availablePoints }),
|
||||
subtext: t('cards.available.subtext'),
|
||||
},
|
||||
{
|
||||
icon: <Redeem />,
|
||||
title: 'Redeemed',
|
||||
text: `${redeemedPoints} points`,
|
||||
subtext: 'Previously used',
|
||||
title: t('cards.redeemed.title'),
|
||||
text: t('leaderboard.points', { count: redeemedPoints }),
|
||||
subtext: t('cards.redeemed.subtext'),
|
||||
},
|
||||
{
|
||||
icon: <AccountBalanceWallet />,
|
||||
title: 'Total',
|
||||
text: `${totalPoints} points`,
|
||||
subtext: 'All time earned',
|
||||
title: t('cards.total.title'),
|
||||
text: t('leaderboard.points', { count: totalPoints }),
|
||||
subtext: t('cards.total.subtext'),
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp />,
|
||||
title: 'Period Points',
|
||||
text: `${periodPoints} points`,
|
||||
subtext: `${tabValue === 24 * 30 ? 'All time' : tabValue === 6 * 30 ? 'Last 6 months' : `Last ${tabValue} days`}`,
|
||||
title: t('cards.period.title'),
|
||||
text: t('leaderboard.points', { count: periodPoints }),
|
||||
subtext:
|
||||
tabValue === 24 * 30
|
||||
? t('cards.period.allTime')
|
||||
: tabValue === 6 * 30
|
||||
? t('cards.period.last6Months')
|
||||
: t('cards.period.lastDays', { count: tabValue }),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -871,24 +877,24 @@ const UserPoints = () => {
|
||||
{/* Current Filter Summary */}
|
||||
<Box sx={{ mb: 3, textAlign: 'center' }}>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Showing points for{' '}
|
||||
{t('summary.showingFor')}{' '}
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||
>
|
||||
{circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName || 'Unknown User'}
|
||||
?.displayName || t('summary.unknownUser')}
|
||||
</Typography>{' '}
|
||||
over the{' '}
|
||||
{t('summary.overThe')}{' '}
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||
>
|
||||
{tabValue === 24 * 30
|
||||
? 'All Time'
|
||||
? t('summary.allTime')
|
||||
: tabValue === 6 * 30
|
||||
? 'Last 6 Months'
|
||||
: `Last ${tabValue} Days`}
|
||||
? t('summary.last6Months')
|
||||
: t('summary.lastDays', { count: tabValue })}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -908,7 +914,7 @@ const UserPoints = () => {
|
||||
level='h4'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Points Trend
|
||||
{t('trend.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { Add, KeyboardArrowDown } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dropdown,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import * as chrono from 'chrono-node'
|
||||
@@ -20,6 +29,7 @@ import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import { CreateLabel } from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { generateUUID } from '../../utils/UUID'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
@@ -122,6 +132,13 @@ const getInitialProject = () => {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const DEFAULT_PROJECT = {
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
color: '#9CA3AF',
|
||||
icon: 'FolderOpen',
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
@@ -175,7 +192,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { isLoading: isProjectsLoading } = useProjects()
|
||||
const { data: projects, isLoading: isProjectsLoading } = useProjects()
|
||||
const createChoreMutation = useCreateChore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -298,6 +315,17 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject)
|
||||
const selectedProject = useMemo(
|
||||
() =>
|
||||
(projectId !== 'default' &&
|
||||
projects?.find(project => project.id === projectId)) ||
|
||||
DEFAULT_PROJECT,
|
||||
[projects, projectId],
|
||||
)
|
||||
const SelectedProjectIcon = useMemo(
|
||||
() => getIconComponent(selectedProject.icon),
|
||||
[selectedProject],
|
||||
)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
|
||||
const [draftId, setDraftId] = useState(() => generateUUID())
|
||||
@@ -1131,6 +1159,50 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
title='Create new task'
|
||||
footer={
|
||||
<ModalActions>
|
||||
{!showScan && !showVoice && projects?.length >= 1 && (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={
|
||||
<SelectedProjectIcon
|
||||
sx={{ fontSize: 18, color: selectedProject.color }}
|
||||
/>
|
||||
}
|
||||
endDecorator={<KeyboardArrowDown sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
mr: 'auto',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
{selectedProject.name}
|
||||
</MenuButton>
|
||||
<Menu
|
||||
placement='top-start'
|
||||
sx={{ minWidth: 200, zIndex: Z_INDEX.MODAL_POPOVER }}
|
||||
>
|
||||
{[DEFAULT_PROJECT, ...projects].map(project => {
|
||||
const ProjectIcon = getIconComponent(project.icon)
|
||||
return (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
selected={projectId === project.id}
|
||||
onClick={() => setProjectId(project.id)}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<ProjectIcon
|
||||
sx={{ fontSize: 18, color: project.color }}
|
||||
/>
|
||||
</ListItemDecorator>
|
||||
{project.name}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
@@ -223,7 +222,7 @@ const AttachmentPickerField = ({
|
||||
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
sx={{ zIndex: 'calc(var(--joy-zIndex-modal) + 1)' }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Chip, List, ListItem, ListItemButton, Textarea } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
|
||||
const { t } = useTranslation('common')
|
||||
const [filteredOptions, setFilteredOptions] = useState([])
|
||||
const [menuVisible, setMenuVisible] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
@@ -82,7 +84,7 @@ const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Type here...'
|
||||
placeholder={t('autocompletePlaceholder')}
|
||||
/>
|
||||
{menuVisible && (
|
||||
<List ref={menuRef} style={{ position: 'absolute', zIndex: 1000 }}>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BaseOptionPicker = ({
|
||||
items = [],
|
||||
@@ -199,7 +198,7 @@ const BaseOptionPicker = ({
|
||||
},
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
sx={{ zIndex: 'calc(var(--joy-zIndex-modal) + 1)' }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import { Box, Button, CircularProgress, Container } from '@mui/joy'
|
||||
import { Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Logo from '../../Logo'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
|
||||
const LoadingComponent = () => {
|
||||
const [message, setMessage] = useState('Loading...')
|
||||
const { t } = useTranslation('common')
|
||||
const [message, setMessage] = useState(t('loading'))
|
||||
const [subMessage, setSubMessage] = useState('')
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnline) {
|
||||
setMessage('You are offline')
|
||||
setSubMessage(
|
||||
'This not available while offline. Please check your internet connection and try again.',
|
||||
)
|
||||
setMessage(t('loadingOffline'))
|
||||
setSubMessage(t('loadingOfflineSub'))
|
||||
}
|
||||
}, [isOnline])
|
||||
}, [isOnline, t])
|
||||
useEffect(() => {
|
||||
networkManager.registerNetworkListener(isOnline => setIsOnline(isOnline))
|
||||
|
||||
// if loading took more than 5 seconds update submessage to mention there might be an error:
|
||||
const timeout = setTimeout(() => {
|
||||
if (networkManager.isOnline) {
|
||||
setSubMessage(
|
||||
'This is taking longer than usual. There might be an issue.',
|
||||
)
|
||||
setSubMessage(t('loadingSlow'))
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
@@ -67,7 +65,7 @@ const LoadingComponent = () => {
|
||||
window.location.href = '/' // navigate back to the home page
|
||||
}}
|
||||
>
|
||||
Navigate Back
|
||||
{t('navigateBack')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
Archive,
|
||||
ArrowBack,
|
||||
BugReport,
|
||||
FilterAlt,
|
||||
FolderOpen,
|
||||
History,
|
||||
@@ -30,10 +31,11 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { version } from '../../../package.json'
|
||||
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useGlobalSearch } from '../../search/GlobalSearchContext'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import ErrorReportModal from '../Modals/ErrorReportModal'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
@@ -45,6 +47,7 @@ const NavBar = () => {
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [bugReportOpen, setBugReportOpen] = useState(false)
|
||||
|
||||
const links = [
|
||||
{
|
||||
@@ -206,7 +209,7 @@ const NavBar = () => {
|
||||
? `calc(var(--safe-area-inset-top, 0px))`
|
||||
: '',
|
||||
position: 'sticky',
|
||||
zIndex: Z_INDEX.NAVBAR,
|
||||
zIndex: 'var(--joy-zIndex-popup)',
|
||||
top: 0,
|
||||
minHeight: '35px',
|
||||
backgroundColor: 'var(--joy-palette-background-body)',
|
||||
@@ -239,7 +242,6 @@ const NavBar = () => {
|
||||
// height:
|
||||
// 'calc(100vh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))',
|
||||
overflow: 'auto',
|
||||
zIndex: Z_INDEX.DRAWER,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -296,6 +298,17 @@ const NavBar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>Upgrade to Plus</ListItemContent>
|
||||
</ListItemButton> */}
|
||||
<ListItemButton
|
||||
onClick={() => setBugReportOpen(true)}
|
||||
sx={{
|
||||
py: 1.2,
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<BugReport />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>{t('navigation.reportBug')}</ListItemContent>
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
apiClient.handleLogout()
|
||||
@@ -329,6 +342,10 @@ const NavBar = () => {
|
||||
</List>
|
||||
</div>
|
||||
</Drawer>
|
||||
<ErrorReportModal
|
||||
open={bugReportOpen}
|
||||
onClose={() => setBugReportOpen(false)}
|
||||
/>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Box from '@mui/joy/Box'
|
||||
import ListItemDecorator from '@mui/joy/ListItemDecorator'
|
||||
import Tabs from '@mui/joy/Tabs'
|
||||
@@ -10,6 +11,7 @@ import Search from '@mui/icons-material/Search'
|
||||
import Person from '@mui/icons-material/Person'
|
||||
|
||||
export default function NavBarMobile() {
|
||||
const { t } = useTranslation('common')
|
||||
const [index, setIndex] = React.useState(0)
|
||||
const colors = ['primary', 'danger', 'success', 'warning']
|
||||
return (
|
||||
@@ -32,7 +34,7 @@ export default function NavBarMobile() {
|
||||
>
|
||||
<Tabs
|
||||
size='lg'
|
||||
aria-label='Bottom Navigation'
|
||||
aria-label={t('bottomNav')}
|
||||
value={index}
|
||||
onChange={(event, value) => setIndex(value)}
|
||||
sx={theme => ({
|
||||
@@ -98,7 +100,7 @@ export default function NavBarMobile() {
|
||||
<ListItemDecorator>
|
||||
<Person />
|
||||
</ListItemDecorator>
|
||||
Profile
|
||||
{t('profile')}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WifiOff } from '@mui/icons-material'
|
||||
import { Alert, Box } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
|
||||
const NetworkBanner = () => {
|
||||
@@ -56,7 +56,7 @@ const NetworkBanner = () => {
|
||||
paddingTop: `calc(var(--safe-area-inset-top, 0px))`,
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: Z_INDEX.NETWORK_BANNER,
|
||||
zIndex: 'var(--joy-zIndex-snackbar)',
|
||||
pt: `calc( env(safe-area-inset-top, 0px))`,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
@@ -25,6 +26,7 @@ const ProjectSelector = ({
|
||||
onProjectSelect,
|
||||
showKeyboardShortcuts = false,
|
||||
}) => {
|
||||
const { t } = useTranslation('projects')
|
||||
const { data: projects = [], isLoading } = useProjects()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -265,7 +267,7 @@ const ProjectSelector = ({
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
Projects
|
||||
{t('selector.title')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -339,7 +341,7 @@ const ProjectSelector = ({
|
||||
: 'var(--joy-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
Default Project
|
||||
{t('chores:toolbar.defaultProject')}
|
||||
</Typography>
|
||||
{effectiveSelectedProject === 'Default Project' && (
|
||||
<Check
|
||||
@@ -477,13 +479,13 @@ const ProjectSelector = ({
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Create New Project
|
||||
{t('selector.createNew')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Add a custom project workspace
|
||||
{t('selector.createNewDescription')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -512,13 +514,13 @@ const ProjectSelector = ({
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Manage Projects
|
||||
{t('selector.manage')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
View, edit, and organize all projects
|
||||
{t('selector.manageDescription')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
|
||||
Reference in New Issue
Block a user