diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9f2a5b1..9e6dbb4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,8 +15,12 @@ "@fullcalendar/timegrid": "^6.1.20", "axios": "^1.6.0", "frappe-gantt": "^1.0.4", + "i18next": "^25.7.4", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^16.5.1", "react-router-dom": "^6.21.0" }, "devDependencies": { @@ -340,7 +344,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1913,6 +1916,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -2382,6 +2394,15 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2410,6 +2431,56 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "25.7.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz", + "integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2615,6 +2686,48 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -2771,6 +2884,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz", + "integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -3063,8 +3203,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3104,6 +3245,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -3779,6 +3929,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index da3228a..8fe41ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,8 +19,12 @@ "@fullcalendar/timegrid": "^6.1.20", "axios": "^1.6.0", "frappe-gantt": "^1.0.4", + "i18next": "^25.7.4", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^16.5.1", "react-router-dom": "^6.21.0" }, "devDependencies": { diff --git a/frontend/public/locales/en/audit.json b/frontend/public/locales/en/audit.json new file mode 100644 index 0000000..4844611 --- /dev/null +++ b/frontend/public/locales/en/audit.json @@ -0,0 +1,56 @@ +{ + "title": "Audit Log", + "subtitle": "Track all operations in the system", + "filters": { + "action": "Action Type", + "user": "User", + "entity": "Entity Type", + "dateRange": "Date Range", + "allActions": "All Actions", + "allUsers": "All Users", + "allEntities": "All Entities" + }, + "actions": { + "create": "Create", + "update": "Update", + "delete": "Delete", + "login": "Login", + "logout": "Logout", + "assign": "Assign", + "unassign": "Unassign", + "statusChange": "Status Change", + "comment": "Comment", + "upload": "Upload", + "download": "Download" + }, + "entities": { + "task": "Task", + "project": "Project", + "space": "Space", + "user": "User", + "comment": "Comment", + "attachment": "Attachment" + }, + "columns": { + "timestamp": "Timestamp", + "user": "User", + "action": "Action", + "entity": "Entity", + "details": "Details", + "ipAddress": "IP Address" + }, + "details": { + "before": "Before", + "after": "After", + "changes": "Changes" + }, + "export": { + "title": "Export Log", + "csv": "Export as CSV", + "json": "Export as JSON" + }, + "empty": { + "title": "No Audit Records", + "description": "No audit records match the current filters" + } +} diff --git a/frontend/public/locales/en/auth.json b/frontend/public/locales/en/auth.json new file mode 100644 index 0000000..746d56e --- /dev/null +++ b/frontend/public/locales/en/auth.json @@ -0,0 +1,28 @@ +{ + "login": { + "title": "Login", + "subtitle": "Sign in to your account", + "email": "Email", + "emailPlaceholder": "Enter your email", + "password": "Password", + "passwordPlaceholder": "Enter your password", + "rememberMe": "Remember me", + "forgotPassword": "Forgot password?", + "submit": "Sign in", + "loggingIn": "Signing in...", + "noAccount": "Don't have an account?", + "signUp": "Sign up" + }, + "errors": { + "invalidCredentials": "Invalid email or password", + "accountLocked": "Account is locked. Please contact administrator.", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "invalidEmail": "Please enter a valid email address", + "loginFailed": "Login failed. Please try again later." + }, + "welcome": { + "title": "Project Control Center", + "subtitle": "Manage your projects, tasks, and teams" + } +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json new file mode 100644 index 0000000..042321a --- /dev/null +++ b/frontend/public/locales/en/common.json @@ -0,0 +1,102 @@ +{ + "buttons": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "close": "Close", + "confirm": "Confirm", + "back": "Back", + "next": "Next", + "previous": "Previous", + "submit": "Submit", + "reset": "Reset", + "search": "Search", + "filter": "Filter", + "export": "Export", + "import": "Import", + "refresh": "Refresh", + "add": "Add", + "remove": "Remove", + "view": "View", + "download": "Download", + "upload": "Upload" + }, + "labels": { + "loading": "Loading...", + "noData": "No data", + "required": "Required", + "optional": "Optional", + "all": "All", + "none": "None", + "yes": "Yes", + "no": "No", + "active": "Active", + "inactive": "Inactive", + "enabled": "Enabled", + "disabled": "Disabled", + "actions": "Actions", + "details": "Details", + "description": "Description", + "name": "Name", + "title": "Title", + "status": "Status", + "type": "Type", + "date": "Date", + "time": "Time", + "createdAt": "Created at", + "updatedAt": "Updated at", + "createdBy": "Created by", + "assignee": "Assignee", + "selectAssignee": "Select assignee...", + "searchUsers": "Search users...", + "noUsersFound": "No users found", + "typeToSearch": "Type to search users" + }, + "messages": { + "success": "Operation successful", + "error": "Operation failed", + "confirmDelete": "Are you sure you want to delete? This action cannot be undone.", + "unsavedChanges": "You have unsaved changes. Are you sure you want to leave?", + "networkError": "Network error. Please try again later.", + "sessionExpired": "Session expired. Please log in again.", + "permissionDenied": "You do not have permission to perform this action.", + "notFound": "The requested resource was not found." + }, + "validation": { + "required": "This field is required", + "email": "Please enter a valid email address", + "minLength": "Minimum {{min}} characters required", + "maxLength": "Maximum {{max}} characters allowed", + "invalidFormat": "Invalid format" + }, + "nav": { + "dashboard": "Dashboard", + "spaces": "Spaces", + "projects": "Projects", + "tasks": "Tasks", + "workload": "Workload", + "health": "Project Health", + "audit": "Audit Log", + "settings": "Settings", + "logout": "Logout" + }, + "language": { + "switch": "Switch language", + "zhTW": "繁體中文", + "en": "English" + }, + "notifications": { + "title": "Notifications", + "markAllRead": "Mark all as read", + "noNotifications": "No notifications", + "viewAll": "View all" + }, + "pagination": { + "page": "Page {{page}}", + "of": "of {{total}}", + "showing": "Showing {{from}}-{{to}} of {{total}}", + "itemsPerPage": "Items per page" + } +} diff --git a/frontend/public/locales/en/dashboard.json b/frontend/public/locales/en/dashboard.json new file mode 100644 index 0000000..390c489 --- /dev/null +++ b/frontend/public/locales/en/dashboard.json @@ -0,0 +1,47 @@ +{ + "title": "Dashboard", + "welcome": "Welcome back, {{name}}", + "stats": { + "myTasks": "My Tasks", + "overdueTasks": "Overdue Tasks", + "completedTasks": "Completed Tasks", + "activeProjects": "Active Projects", + "teamMembers": "Team Members", + "totalTasks": "Total Tasks", + "pendingReview": "Pending Review" + }, + "sections": { + "recentActivity": "Recent Activity", + "upcomingDeadlines": "Upcoming Deadlines", + "myAssignedTasks": "Tasks Assigned to Me", + "projectOverview": "Project Overview", + "quickActions": "Quick Actions" + }, + "activity": { + "taskCreated": "Created task \"{{task}}\"", + "taskCompleted": "Completed task \"{{task}}\"", + "taskAssigned": "Assigned task \"{{task}}\" to {{assignee}}", + "commentAdded": "Added a comment on task \"{{task}}\"", + "projectCreated": "Created project \"{{project}}\"", + "noRecentActivity": "No recent activity" + }, + "deadlines": { + "today": "Due today", + "tomorrow": "Due tomorrow", + "thisWeek": "Due this week", + "overdue": "Overdue", + "noUpcoming": "No upcoming deadlines" + }, + "quickActions": { + "createTask": "Create Task", + "createProject": "Create Project", + "viewAllTasks": "View All Tasks", + "viewWorkload": "View Workload" + }, + "widgets": { + "tasksByStatus": "Tasks by Status", + "tasksByPriority": "Tasks by Priority", + "projectProgress": "Project Progress", + "teamWorkload": "Team Workload" + } +} diff --git a/frontend/public/locales/en/health.json b/frontend/public/locales/en/health.json new file mode 100644 index 0000000..e9d06e4 --- /dev/null +++ b/frontend/public/locales/en/health.json @@ -0,0 +1,48 @@ +{ + "title": "Project Health", + "subtitle": "Monitor the overall health of projects", + "overall": { + "title": "Overall Health", + "healthy": "Healthy", + "atRisk": "At Risk", + "critical": "Critical" + }, + "metrics": { + "schedule": "Schedule", + "budget": "Budget", + "scope": "Scope", + "quality": "Quality", + "resources": "Resources" + }, + "status": { + "onTrack": "On Track", + "delayed": "Delayed", + "ahead": "Ahead", + "overBudget": "Over Budget", + "underBudget": "Under Budget" + }, + "indicators": { + "title": "Health Indicators", + "taskCompletion": "Task Completion Rate", + "onTimeDelivery": "On-time Delivery Rate", + "blockedTasks": "Blocked Tasks", + "overdueRate": "Overdue Rate", + "velocityTrend": "Velocity Trend" + }, + "risks": { + "title": "Risks", + "high": "High Risk", + "medium": "Medium Risk", + "low": "Low Risk", + "mitigated": "Mitigated" + }, + "actions": { + "viewDetails": "View Details", + "exportReport": "Export Report", + "setAlert": "Set Alert" + }, + "empty": { + "title": "No Health Data", + "description": "The project needs more data to display health metrics" + } +} diff --git a/frontend/public/locales/en/projects.json b/frontend/public/locales/en/projects.json new file mode 100644 index 0000000..34f9f37 --- /dev/null +++ b/frontend/public/locales/en/projects.json @@ -0,0 +1,50 @@ +{ + "title": "Projects", + "createProject": "Create Project", + "editProject": "Edit Project", + "deleteProject": "Delete Project", + "projectSettings": "Project Settings", + "fields": { + "name": "Name", + "namePlaceholder": "Enter project name", + "description": "Description", + "descriptionPlaceholder": "Enter project description", + "status": "Status", + "startDate": "Start Date", + "endDate": "End Date", + "owner": "Project Owner", + "space": "Space" + }, + "status": { + "planning": "Planning", + "active": "Active", + "on_hold": "On Hold", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "tabs": { + "overview": "Overview", + "tasks": "Tasks", + "members": "Members", + "settings": "Settings", + "files": "Files", + "activity": "Activity" + }, + "stats": { + "totalTasks": "Total Tasks", + "completedTasks": "Completed", + "inProgress": "In Progress", + "overdue": "Overdue", + "progress": "Overall Progress" + }, + "messages": { + "created": "Project created", + "updated": "Project updated", + "deleted": "Project deleted", + "confirmDelete": "Are you sure you want to delete this project? This will delete all related tasks." + }, + "empty": { + "title": "No Projects", + "description": "Create your first project to start managing tasks" + } +} diff --git a/frontend/public/locales/en/settings.json b/frontend/public/locales/en/settings.json new file mode 100644 index 0000000..87a1d74 --- /dev/null +++ b/frontend/public/locales/en/settings.json @@ -0,0 +1,73 @@ +{ + "title": "Settings", + "projectSettings": "Project Settings", + "tabs": { + "general": "General", + "members": "Members", + "customFields": "Custom Fields", + "notifications": "Notifications", + "integrations": "Integrations", + "danger": "Danger Zone" + }, + "general": { + "title": "General Settings", + "projectName": "Project Name", + "description": "Description", + "status": "Status", + "visibility": "Visibility", + "public": "Public", + "private": "Private" + }, + "members": { + "title": "Member Management", + "invite": "Invite Member", + "inviteByEmail": "Invite by email", + "emailPlaceholder": "Enter email address", + "role": "Role", + "changeRole": "Change Role", + "remove": "Remove Member", + "confirmRemove": "Are you sure you want to remove this member?" + }, + "customFields": { + "title": "Custom Fields", + "add": "Add Field", + "edit": "Edit Field", + "delete": "Delete Field", + "fieldName": "Field Name", + "fieldType": "Field Type", + "required": "Required", + "types": { + "text": "Text", + "number": "Number", + "date": "Date", + "select": "Dropdown", + "multiSelect": "Multi-select", + "checkbox": "Checkbox" + } + }, + "notifications": { + "title": "Notification Settings", + "email": "Email Notifications", + "inApp": "In-app Notifications", + "taskAssigned": "When a task is assigned to me", + "taskCompleted": "When a task is completed", + "commentAdded": "When a comment is added", + "dueDateApproaching": "When a due date is approaching" + }, + "danger": { + "title": "Danger Zone", + "archive": "Archive Project", + "archiveDescription": "Archive this project. The project will become read-only.", + "delete": "Delete Project", + "deleteDescription": "Permanently delete this project and all its data. This action cannot be undone.", + "confirmArchive": "Are you sure you want to archive this project?", + "confirmDelete": "Are you sure you want to delete this project? Type the project name to confirm:", + "typeToConfirm": "Type \"{{name}}\" to confirm" + }, + "messages": { + "saved": "Settings saved", + "memberInvited": "Invitation sent", + "memberRemoved": "Member removed", + "roleChanged": "Role changed" + } +} diff --git a/frontend/public/locales/en/spaces.json b/frontend/public/locales/en/spaces.json new file mode 100644 index 0000000..546ff9c --- /dev/null +++ b/frontend/public/locales/en/spaces.json @@ -0,0 +1,39 @@ +{ + "title": "Spaces", + "createSpace": "Create Space", + "editSpace": "Edit Space", + "deleteSpace": "Delete Space", + "fields": { + "name": "Name", + "namePlaceholder": "Enter space name", + "description": "Description", + "descriptionPlaceholder": "Enter space description", + "icon": "Icon", + "color": "Color" + }, + "members": { + "title": "Members", + "add": "Add Member", + "remove": "Remove Member", + "role": "Role", + "owner": "Owner", + "admin": "Admin", + "member": "Member", + "viewer": "Viewer" + }, + "stats": { + "projects": "Projects", + "members": "Members", + "tasks": "Tasks" + }, + "messages": { + "created": "Space created", + "updated": "Space updated", + "deleted": "Space deleted", + "confirmDelete": "Are you sure you want to delete this space? This will delete all related projects and tasks." + }, + "empty": { + "title": "No Spaces", + "description": "Create your first space to organize projects" + } +} diff --git a/frontend/public/locales/en/tasks.json b/frontend/public/locales/en/tasks.json new file mode 100644 index 0000000..a027b8b --- /dev/null +++ b/frontend/public/locales/en/tasks.json @@ -0,0 +1,134 @@ +{ + "title": "Tasks", + "createTask": "Create Task", + "editTask": "Edit Task", + "deleteTask": "Delete Task", + "taskDetails": "Task Details", + "fields": { + "title": "Title", + "titlePlaceholder": "Enter task title", + "description": "Description", + "descriptionPlaceholder": "Enter task description", + "status": "Status", + "priority": "Priority", + "assignee": "Assignee", + "dueDate": "Due Date", + "startDate": "Start Date", + "estimatedHours": "Estimated Hours", + "actualHours": "Actual Hours", + "progress": "Progress", + "tags": "Tags", + "parent": "Parent Task", + "subtasks": "Subtasks", + "attachments": "Attachments", + "comments": "Comments", + "watchers": "Watchers", + "blockers": "Blockers" + }, + "status": { + "todo": "To Do", + "in_progress": "In Progress", + "review": "In Review", + "done": "Done", + "cancelled": "Cancelled", + "blocked": "Blocked" + }, + "priority": { + "low": "Low", + "medium": "Medium", + "high": "High", + "urgent": "Urgent" + }, + "views": { + "list": "List", + "kanban": "Kanban", + "calendar": "Calendar", + "gantt": "Gantt", + "timeline": "Timeline" + }, + "filters": { + "all": "All Tasks", + "myTasks": "My Tasks", + "unassigned": "Unassigned", + "overdue": "Overdue", + "dueThisWeek": "Due This Week", + "highPriority": "High Priority", + "recentlyUpdated": "Recently Updated" + }, + "sort": { + "sortBy": "Sort by", + "dueDate": "Due Date", + "priority": "Priority", + "status": "Status", + "title": "Title", + "createdAt": "Created At", + "updatedAt": "Updated At", + "ascending": "Ascending", + "descending": "Descending" + }, + "actions": { + "assign": "Assign", + "reassign": "Reassign", + "changeStatus": "Change Status", + "changePriority": "Change Priority", + "addSubtask": "Add Subtask", + "addComment": "Add Comment", + "addAttachment": "Add Attachment", + "addWatcher": "Add Watcher", + "removeWatcher": "Remove Watcher", + "duplicate": "Duplicate Task", + "archive": "Archive", + "restore": "Restore", + "moveToProject": "Move to Project" + }, + "subtasks": { + "title": "Subtasks", + "add": "Add Subtask", + "placeholder": "Enter subtask title", + "completed": "{{count}} / {{total}} completed", + "empty": "No subtasks" + }, + "comments": { + "title": "Comments", + "add": "Add Comment", + "placeholder": "Write your comment...", + "edited": "edited", + "delete": "Delete Comment", + "confirmDelete": "Are you sure you want to delete this comment?", + "empty": "No comments yet", + "reply": "Reply" + }, + "attachments": { + "title": "Attachments", + "add": "Add Attachment", + "upload": "Upload File", + "dragDrop": "Drag and drop files here or click to upload", + "maxSize": "Max file size: {{size}}MB", + "downloading": "Downloading...", + "empty": "No attachments" + }, + "blockers": { + "title": "Blockers", + "add": "Add Blocker", + "blockedBy": "Blocked by", + "blocking": "Blocking", + "remove": "Remove blocker", + "empty": "No blockers" + }, + "messages": { + "created": "Task created", + "updated": "Task updated", + "deleted": "Task deleted", + "statusChanged": "Status changed to \"{{status}}\"", + "assigned": "Assigned to {{assignee}}", + "unassigned": "Unassigned", + "commentAdded": "Comment added", + "attachmentUploaded": "Attachment uploaded", + "confirmDelete": "Are you sure you want to delete this task? This action cannot be undone." + }, + "empty": { + "title": "No Tasks", + "description": "There are no tasks yet. Create your first task to get started!", + "filtered": "No tasks match your filters" + } +} diff --git a/frontend/public/locales/en/workload.json b/frontend/public/locales/en/workload.json new file mode 100644 index 0000000..84b4a23 --- /dev/null +++ b/frontend/public/locales/en/workload.json @@ -0,0 +1,42 @@ +{ + "title": "Workload", + "subtitle": "View team member workload distribution", + "filters": { + "project": "Project", + "allProjects": "All Projects", + "dateRange": "Date Range", + "thisWeek": "This Week", + "thisMonth": "This Month", + "custom": "Custom" + }, + "metrics": { + "totalHours": "Total Hours", + "assignedTasks": "Assigned Tasks", + "completedTasks": "Completed Tasks", + "overdueTasks": "Overdue Tasks", + "utilization": "Utilization" + }, + "chart": { + "hoursPerDay": "Hours per Day", + "taskDistribution": "Task Distribution", + "byProject": "By Project", + "byPriority": "By Priority" + }, + "team": { + "title": "Team Members", + "member": "Member", + "allocated": "Allocated", + "available": "Available", + "overloaded": "Overloaded", + "underutilized": "Underutilized" + }, + "status": { + "balanced": "Balanced", + "overloaded": "Overloaded", + "underutilized": "Underutilized" + }, + "empty": { + "title": "No Workload Data", + "description": "Not enough data to display workload" + } +} diff --git a/frontend/public/locales/zh-TW/audit.json b/frontend/public/locales/zh-TW/audit.json new file mode 100644 index 0000000..5511424 --- /dev/null +++ b/frontend/public/locales/zh-TW/audit.json @@ -0,0 +1,56 @@ +{ + "title": "稽核日誌", + "subtitle": "追蹤系統中的所有操作記錄", + "filters": { + "action": "操作類型", + "user": "使用者", + "entity": "實體類型", + "dateRange": "日期範圍", + "allActions": "所有操作", + "allUsers": "所有使用者", + "allEntities": "所有實體" + }, + "actions": { + "create": "建立", + "update": "更新", + "delete": "刪除", + "login": "登入", + "logout": "登出", + "assign": "指派", + "unassign": "取消指派", + "statusChange": "狀態變更", + "comment": "留言", + "upload": "上傳", + "download": "下載" + }, + "entities": { + "task": "任務", + "project": "專案", + "space": "工作空間", + "user": "使用者", + "comment": "留言", + "attachment": "附件" + }, + "columns": { + "timestamp": "時間", + "user": "使用者", + "action": "操作", + "entity": "實體", + "details": "詳情", + "ipAddress": "IP 位址" + }, + "details": { + "before": "變更前", + "after": "變更後", + "changes": "變更內容" + }, + "export": { + "title": "匯出日誌", + "csv": "匯出為 CSV", + "json": "匯出為 JSON" + }, + "empty": { + "title": "沒有稽核記錄", + "description": "目前沒有符合條件的稽核記錄" + } +} diff --git a/frontend/public/locales/zh-TW/auth.json b/frontend/public/locales/zh-TW/auth.json new file mode 100644 index 0000000..eff4189 --- /dev/null +++ b/frontend/public/locales/zh-TW/auth.json @@ -0,0 +1,28 @@ +{ + "login": { + "title": "登入", + "subtitle": "登入您的帳戶", + "email": "電子郵件", + "emailPlaceholder": "請輸入電子郵件", + "password": "密碼", + "passwordPlaceholder": "請輸入密碼", + "rememberMe": "記住我", + "forgotPassword": "忘記密碼?", + "submit": "登入", + "loggingIn": "登入中...", + "noAccount": "還沒有帳戶?", + "signUp": "註冊" + }, + "errors": { + "invalidCredentials": "電子郵件或密碼錯誤", + "accountLocked": "帳戶已被鎖定,請聯繫管理員", + "emailRequired": "請輸入電子郵件", + "passwordRequired": "請輸入密碼", + "invalidEmail": "請輸入有效的電子郵件地址", + "loginFailed": "登入失敗,請稍後再試" + }, + "welcome": { + "title": "專案控制中心", + "subtitle": "管理您的專案、任務和團隊" + } +} diff --git a/frontend/public/locales/zh-TW/common.json b/frontend/public/locales/zh-TW/common.json new file mode 100644 index 0000000..1d49df9 --- /dev/null +++ b/frontend/public/locales/zh-TW/common.json @@ -0,0 +1,102 @@ +{ + "buttons": { + "save": "儲存", + "cancel": "取消", + "delete": "刪除", + "edit": "編輯", + "create": "建立", + "close": "關閉", + "confirm": "確認", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "submit": "提交", + "reset": "重置", + "search": "搜尋", + "filter": "篩選", + "export": "匯出", + "import": "匯入", + "refresh": "重新整理", + "add": "新增", + "remove": "移除", + "view": "檢視", + "download": "下載", + "upload": "上傳" + }, + "labels": { + "loading": "載入中...", + "noData": "暫無資料", + "required": "必填", + "optional": "選填", + "all": "全部", + "none": "無", + "yes": "是", + "no": "否", + "active": "啟用", + "inactive": "停用", + "enabled": "已啟用", + "disabled": "已停用", + "actions": "操作", + "details": "詳情", + "description": "描述", + "name": "名稱", + "title": "標題", + "status": "狀態", + "type": "類型", + "date": "日期", + "time": "時間", + "createdAt": "建立時間", + "updatedAt": "更新時間", + "createdBy": "建立者", + "assignee": "負責人", + "selectAssignee": "選擇負責人...", + "searchUsers": "搜尋使用者...", + "noUsersFound": "找不到使用者", + "typeToSearch": "輸入以搜尋使用者" + }, + "messages": { + "success": "操作成功", + "error": "操作失敗", + "confirmDelete": "確定要刪除嗎?此操作無法復原。", + "unsavedChanges": "您有未儲存的變更,確定要離開嗎?", + "networkError": "網路連線錯誤,請稍後再試", + "sessionExpired": "工作階段已過期,請重新登入", + "permissionDenied": "您沒有權限執行此操作", + "notFound": "找不到請求的資源" + }, + "validation": { + "required": "此欄位為必填", + "email": "請輸入有效的電子郵件地址", + "minLength": "至少需要 {{min}} 個字元", + "maxLength": "最多 {{max}} 個字元", + "invalidFormat": "格式不正確" + }, + "nav": { + "dashboard": "儀表板", + "spaces": "工作空間", + "projects": "專案", + "tasks": "任務", + "workload": "工作負載", + "health": "專案健康度", + "audit": "稽核日誌", + "settings": "設定", + "logout": "登出" + }, + "language": { + "switch": "切換語言", + "zhTW": "繁體中文", + "en": "English" + }, + "notifications": { + "title": "通知", + "markAllRead": "全部標為已讀", + "noNotifications": "沒有通知", + "viewAll": "查看全部" + }, + "pagination": { + "page": "第 {{page}} 頁", + "of": "共 {{total}} 頁", + "showing": "顯示 {{from}}-{{to}} 筆,共 {{total}} 筆", + "itemsPerPage": "每頁顯示" + } +} diff --git a/frontend/public/locales/zh-TW/dashboard.json b/frontend/public/locales/zh-TW/dashboard.json new file mode 100644 index 0000000..4ded666 --- /dev/null +++ b/frontend/public/locales/zh-TW/dashboard.json @@ -0,0 +1,47 @@ +{ + "title": "儀表板", + "welcome": "歡迎回來,{{name}}", + "stats": { + "myTasks": "我的任務", + "overdueTasks": "逾期任務", + "completedTasks": "已完成任務", + "activeProjects": "進行中專案", + "teamMembers": "團隊成員", + "totalTasks": "總任務數", + "pendingReview": "待審核" + }, + "sections": { + "recentActivity": "近期活動", + "upcomingDeadlines": "即將到期", + "myAssignedTasks": "指派給我的任務", + "projectOverview": "專案概覽", + "quickActions": "快速操作" + }, + "activity": { + "taskCreated": "建立了任務「{{task}}」", + "taskCompleted": "完成了任務「{{task}}」", + "taskAssigned": "將任務「{{task}}」指派給 {{assignee}}", + "commentAdded": "在任務「{{task}}」新增了留言", + "projectCreated": "建立了專案「{{project}}」", + "noRecentActivity": "暫無近期活動" + }, + "deadlines": { + "today": "今天到期", + "tomorrow": "明天到期", + "thisWeek": "本週到期", + "overdue": "已逾期", + "noUpcoming": "近期沒有待處理的截止日期" + }, + "quickActions": { + "createTask": "建立任務", + "createProject": "建立專案", + "viewAllTasks": "查看所有任務", + "viewWorkload": "查看工作負載" + }, + "widgets": { + "tasksByStatus": "依狀態分類的任務", + "tasksByPriority": "依優先順序分類的任務", + "projectProgress": "專案進度", + "teamWorkload": "團隊工作負載" + } +} diff --git a/frontend/public/locales/zh-TW/health.json b/frontend/public/locales/zh-TW/health.json new file mode 100644 index 0000000..5b3eafb --- /dev/null +++ b/frontend/public/locales/zh-TW/health.json @@ -0,0 +1,48 @@ +{ + "title": "專案健康度", + "subtitle": "監控專案的整體健康狀況", + "overall": { + "title": "整體健康度", + "healthy": "健康", + "atRisk": "風險中", + "critical": "危急" + }, + "metrics": { + "schedule": "進度", + "budget": "預算", + "scope": "範圍", + "quality": "品質", + "resources": "資源" + }, + "status": { + "onTrack": "正常進行", + "delayed": "延遲", + "ahead": "超前", + "overBudget": "超支", + "underBudget": "低於預算" + }, + "indicators": { + "title": "健康指標", + "taskCompletion": "任務完成率", + "onTimeDelivery": "準時交付率", + "blockedTasks": "阻擋任務數", + "overdueRate": "逾期率", + "velocityTrend": "速度趨勢" + }, + "risks": { + "title": "風險", + "high": "高風險", + "medium": "中風險", + "low": "低風險", + "mitigated": "已緩解" + }, + "actions": { + "viewDetails": "查看詳情", + "exportReport": "匯出報告", + "setAlert": "設定警示" + }, + "empty": { + "title": "沒有健康度資料", + "description": "專案需要更多資料才能顯示健康度指標" + } +} diff --git a/frontend/public/locales/zh-TW/projects.json b/frontend/public/locales/zh-TW/projects.json new file mode 100644 index 0000000..07bf497 --- /dev/null +++ b/frontend/public/locales/zh-TW/projects.json @@ -0,0 +1,50 @@ +{ + "title": "專案", + "createProject": "建立專案", + "editProject": "編輯專案", + "deleteProject": "刪除專案", + "projectSettings": "專案設定", + "fields": { + "name": "名稱", + "namePlaceholder": "輸入專案名稱", + "description": "描述", + "descriptionPlaceholder": "輸入專案描述", + "status": "狀態", + "startDate": "開始日期", + "endDate": "結束日期", + "owner": "專案負責人", + "space": "工作空間" + }, + "status": { + "planning": "規劃中", + "active": "進行中", + "on_hold": "暫停", + "completed": "已完成", + "cancelled": "已取消" + }, + "tabs": { + "overview": "概覽", + "tasks": "任務", + "members": "成員", + "settings": "設定", + "files": "檔案", + "activity": "活動紀錄" + }, + "stats": { + "totalTasks": "總任務數", + "completedTasks": "已完成", + "inProgress": "進行中", + "overdue": "逾期", + "progress": "整體進度" + }, + "messages": { + "created": "專案已建立", + "updated": "專案已更新", + "deleted": "專案已刪除", + "confirmDelete": "確定要刪除此專案嗎?此操作將刪除所有相關任務。" + }, + "empty": { + "title": "沒有專案", + "description": "建立您的第一個專案來開始管理任務" + } +} diff --git a/frontend/public/locales/zh-TW/settings.json b/frontend/public/locales/zh-TW/settings.json new file mode 100644 index 0000000..cc9af24 --- /dev/null +++ b/frontend/public/locales/zh-TW/settings.json @@ -0,0 +1,73 @@ +{ + "title": "設定", + "projectSettings": "專案設定", + "tabs": { + "general": "一般", + "members": "成員", + "customFields": "自訂欄位", + "notifications": "通知", + "integrations": "整合", + "danger": "危險區域" + }, + "general": { + "title": "一般設定", + "projectName": "專案名稱", + "description": "描述", + "status": "狀態", + "visibility": "可見性", + "public": "公開", + "private": "私人" + }, + "members": { + "title": "成員管理", + "invite": "邀請成員", + "inviteByEmail": "透過電子郵件邀請", + "emailPlaceholder": "輸入電子郵件地址", + "role": "角色", + "changeRole": "變更角色", + "remove": "移除成員", + "confirmRemove": "確定要移除此成員嗎?" + }, + "customFields": { + "title": "自訂欄位", + "add": "新增欄位", + "edit": "編輯欄位", + "delete": "刪除欄位", + "fieldName": "欄位名稱", + "fieldType": "欄位類型", + "required": "必填", + "types": { + "text": "文字", + "number": "數字", + "date": "日期", + "select": "下拉選單", + "multiSelect": "多選", + "checkbox": "核取方塊" + } + }, + "notifications": { + "title": "通知設定", + "email": "電子郵件通知", + "inApp": "應用內通知", + "taskAssigned": "任務指派給我時", + "taskCompleted": "任務完成時", + "commentAdded": "新增留言時", + "dueDateApproaching": "截止日期即將到來時" + }, + "danger": { + "title": "危險區域", + "archive": "封存專案", + "archiveDescription": "封存此專案。封存後專案將變為唯讀。", + "delete": "刪除專案", + "deleteDescription": "永久刪除此專案及其所有資料。此操作無法復原。", + "confirmArchive": "確定要封存此專案嗎?", + "confirmDelete": "確定要刪除此專案嗎?請輸入專案名稱以確認:", + "typeToConfirm": "輸入「{{name}}」以確認" + }, + "messages": { + "saved": "設定已儲存", + "memberInvited": "已發送邀請", + "memberRemoved": "成員已移除", + "roleChanged": "角色已變更" + } +} diff --git a/frontend/public/locales/zh-TW/spaces.json b/frontend/public/locales/zh-TW/spaces.json new file mode 100644 index 0000000..3762f94 --- /dev/null +++ b/frontend/public/locales/zh-TW/spaces.json @@ -0,0 +1,39 @@ +{ + "title": "工作空間", + "createSpace": "建立工作空間", + "editSpace": "編輯工作空間", + "deleteSpace": "刪除工作空間", + "fields": { + "name": "名稱", + "namePlaceholder": "輸入工作空間名稱", + "description": "描述", + "descriptionPlaceholder": "輸入工作空間描述", + "icon": "圖示", + "color": "顏色" + }, + "members": { + "title": "成員", + "add": "新增成員", + "remove": "移除成員", + "role": "角色", + "owner": "擁有者", + "admin": "管理員", + "member": "成員", + "viewer": "檢視者" + }, + "stats": { + "projects": "專案數", + "members": "成員數", + "tasks": "任務數" + }, + "messages": { + "created": "工作空間已建立", + "updated": "工作空間已更新", + "deleted": "工作空間已刪除", + "confirmDelete": "確定要刪除此工作空間嗎?此操作將刪除所有相關專案和任務。" + }, + "empty": { + "title": "沒有工作空間", + "description": "建立您的第一個工作空間來組織專案" + } +} diff --git a/frontend/public/locales/zh-TW/tasks.json b/frontend/public/locales/zh-TW/tasks.json new file mode 100644 index 0000000..6eece02 --- /dev/null +++ b/frontend/public/locales/zh-TW/tasks.json @@ -0,0 +1,134 @@ +{ + "title": "任務", + "createTask": "建立任務", + "editTask": "編輯任務", + "deleteTask": "刪除任務", + "taskDetails": "任務詳情", + "fields": { + "title": "標題", + "titlePlaceholder": "輸入任務標題", + "description": "描述", + "descriptionPlaceholder": "輸入任務描述", + "status": "狀態", + "priority": "優先順序", + "assignee": "負責人", + "dueDate": "截止日期", + "startDate": "開始日期", + "estimatedHours": "預估工時", + "actualHours": "實際工時", + "progress": "進度", + "tags": "標籤", + "parent": "父任務", + "subtasks": "子任務", + "attachments": "附件", + "comments": "留言", + "watchers": "關注者", + "blockers": "阻擋項目" + }, + "status": { + "todo": "待處理", + "in_progress": "進行中", + "review": "審核中", + "done": "已完成", + "cancelled": "已取消", + "blocked": "被阻擋" + }, + "priority": { + "low": "低", + "medium": "中", + "high": "高", + "urgent": "緊急" + }, + "views": { + "list": "列表", + "kanban": "看板", + "calendar": "日曆", + "gantt": "甘特圖", + "timeline": "時間軸" + }, + "filters": { + "all": "全部任務", + "myTasks": "我的任務", + "unassigned": "未指派", + "overdue": "逾期", + "dueThisWeek": "本週到期", + "highPriority": "高優先順序", + "recentlyUpdated": "最近更新" + }, + "sort": { + "sortBy": "排序方式", + "dueDate": "截止日期", + "priority": "優先順序", + "status": "狀態", + "title": "標題", + "createdAt": "建立時間", + "updatedAt": "更新時間", + "ascending": "升序", + "descending": "降序" + }, + "actions": { + "assign": "指派", + "reassign": "重新指派", + "changeStatus": "變更狀態", + "changePriority": "變更優先順序", + "addSubtask": "新增子任務", + "addComment": "新增留言", + "addAttachment": "新增附件", + "addWatcher": "新增關注者", + "removeWatcher": "移除關注者", + "duplicate": "複製任務", + "archive": "封存", + "restore": "還原", + "moveToProject": "移至專案" + }, + "subtasks": { + "title": "子任務", + "add": "新增子任務", + "placeholder": "輸入子任務標題", + "completed": "已完成 {{count}} / {{total}}", + "empty": "沒有子任務" + }, + "comments": { + "title": "留言", + "add": "新增留言", + "placeholder": "輸入您的留言...", + "edited": "已編輯", + "delete": "刪除留言", + "confirmDelete": "確定要刪除此留言嗎?", + "empty": "還沒有留言", + "reply": "回覆" + }, + "attachments": { + "title": "附件", + "add": "新增附件", + "upload": "上傳檔案", + "dragDrop": "拖放檔案至此或點擊上傳", + "maxSize": "最大檔案大小:{{size}}MB", + "downloading": "下載中...", + "empty": "沒有附件" + }, + "blockers": { + "title": "阻擋項目", + "add": "新增阻擋項目", + "blockedBy": "被以下任務阻擋", + "blocking": "正在阻擋以下任務", + "remove": "移除阻擋關係", + "empty": "沒有阻擋項目" + }, + "messages": { + "created": "任務已建立", + "updated": "任務已更新", + "deleted": "任務已刪除", + "statusChanged": "狀態已變更為「{{status}}」", + "assigned": "已指派給 {{assignee}}", + "unassigned": "已取消指派", + "commentAdded": "留言已新增", + "attachmentUploaded": "附件已上傳", + "confirmDelete": "確定要刪除此任務嗎?此操作無法復原。" + }, + "empty": { + "title": "沒有任務", + "description": "目前沒有任務。建立您的第一個任務開始吧!", + "filtered": "沒有符合篩選條件的任務" + } +} diff --git a/frontend/public/locales/zh-TW/workload.json b/frontend/public/locales/zh-TW/workload.json new file mode 100644 index 0000000..043b1e1 --- /dev/null +++ b/frontend/public/locales/zh-TW/workload.json @@ -0,0 +1,42 @@ +{ + "title": "工作負載", + "subtitle": "檢視團隊成員的工作負載分佈", + "filters": { + "project": "專案", + "allProjects": "所有專案", + "dateRange": "日期範圍", + "thisWeek": "本週", + "thisMonth": "本月", + "custom": "自訂" + }, + "metrics": { + "totalHours": "總工時", + "assignedTasks": "指派任務數", + "completedTasks": "已完成任務", + "overdueTasks": "逾期任務", + "utilization": "使用率" + }, + "chart": { + "hoursPerDay": "每日工時", + "taskDistribution": "任務分佈", + "byProject": "依專案", + "byPriority": "依優先順序" + }, + "team": { + "title": "團隊成員", + "member": "成員", + "allocated": "已分配", + "available": "可用", + "overloaded": "超載", + "underutilized": "低使用率" + }, + "status": { + "balanced": "平衡", + "overloaded": "超載", + "underutilized": "低使用率" + }, + "empty": { + "title": "沒有工作負載資料", + "description": "目前沒有足夠的資料來顯示工作負載" + } +} diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..b2b9577 --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -0,0 +1,139 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +const languages = [ + { code: 'zh-TW', label: '繁體中文', flag: '🇹🇼' }, + { code: 'en', label: 'English', flag: '🇺🇸' }, +] as const + +type LanguageCode = (typeof languages)[number]['code'] + +export function LanguageSwitcher() { + const { i18n, t } = useTranslation('common') + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + const currentLanguage = languages.find((lang) => lang.code === i18n.language) || languages[0] + + const handleChangeLanguage = (langCode: LanguageCode) => { + i18n.changeLanguage(langCode) + setIsOpen(false) + } + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + return ( +
+ + + {isOpen && ( +
+ {languages.map((lang) => ( + + ))} +
+ )} +
+ ) +} + +const styles: Record = { + container: { + position: 'relative', + }, + button: { + display: 'flex', + alignItems: 'center', + gap: '6px', + padding: '6px 12px', + backgroundColor: 'transparent', + border: '1px solid #ddd', + borderRadius: '6px', + cursor: 'pointer', + fontSize: '14px', + color: '#333', + transition: 'all 0.2s', + }, + flag: { + fontSize: '16px', + }, + code: { + fontWeight: 500, + fontSize: '12px', + }, + arrow: { + fontSize: '8px', + color: '#666', + marginLeft: '2px', + }, + dropdown: { + position: 'absolute', + top: '100%', + right: 0, + marginTop: '4px', + backgroundColor: 'white', + border: '1px solid #ddd', + borderRadius: '8px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + zIndex: 1000, + minWidth: '150px', + overflow: 'hidden', + }, + option: { + display: 'flex', + alignItems: 'center', + gap: '8px', + width: '100%', + padding: '10px 12px', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + fontSize: '14px', + color: '#333', + textAlign: 'left', + transition: 'background-color 0.2s', + }, + optionActive: { + backgroundColor: '#f0f7ff', + }, + label: { + flex: 1, + }, + check: { + color: '#0066cc', + fontWeight: 'bold', + }, +} + +export default LanguageSwitcher diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9d34410..81ae8c6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,13 +1,16 @@ import { ReactNode } from 'react' import { useNavigate, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { useAuth } from '../contexts/AuthContext' import { NotificationBell } from './NotificationBell' +import { LanguageSwitcher } from './LanguageSwitcher' interface LayoutProps { children: ReactNode } export default function Layout({ children }: LayoutProps) { + const { t } = useTranslation('common') const { user, logout } = useAuth() const navigate = useNavigate() const location = useLocation() @@ -17,11 +20,11 @@ export default function Layout({ children }: LayoutProps) { } const navItems = [ - { path: '/', label: 'Dashboard' }, - { path: '/spaces', label: 'Spaces' }, - { path: '/workload', label: 'Workload' }, - { path: '/project-health', label: 'Health' }, - ...(user?.is_system_admin ? [{ path: '/audit', label: 'Audit' }] : []), + { path: '/', labelKey: 'nav.dashboard' }, + { path: '/spaces', labelKey: 'nav.spaces' }, + { path: '/workload', labelKey: 'nav.workload' }, + { path: '/project-health', labelKey: 'nav.health' }, + ...(user?.is_system_admin ? [{ path: '/audit', labelKey: 'nav.audit' }] : []), ] return ( @@ -41,19 +44,20 @@ export default function Layout({ children }: LayoutProps) { ...(location.pathname === item.path ? styles.navItemActive : {}), }} > - {item.label} + {t(item.labelKey)} ))}
+ {user?.name} {user?.is_system_admin && ( Admin )}
diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..0ff50ff --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,55 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import LanguageDetector from 'i18next-browser-languagedetector' +import HttpBackend from 'i18next-http-backend' + +const SUPPORTED_LANGUAGES = ['zh-TW', 'en'] as const +const DEFAULT_LANGUAGE = 'zh-TW' + +// Namespaces for translation files +export const NAMESPACES = [ + 'common', + 'auth', + 'dashboard', + 'tasks', + 'projects', + 'spaces', + 'settings', + 'workload', + 'health', + 'audit', +] as const + +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number] +export type Namespace = (typeof NAMESPACES)[number] + +i18n + .use(HttpBackend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: DEFAULT_LANGUAGE, + supportedLngs: SUPPORTED_LANGUAGES, + defaultNS: 'common', + ns: NAMESPACES, + + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + }, + + detection: { + order: ['localStorage', 'navigator'], + lookupLocalStorage: 'i18nextLng', + caches: ['localStorage'], + }, + + interpolation: { + escapeValue: false, // React already escapes values + }, + + react: { + useSuspense: true, + }, + }) + +export default i18n diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c14ac13..23d8db7 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { Suspense } from 'react' import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App' @@ -6,20 +6,36 @@ import { AuthProvider } from './contexts/AuthContext' import { NotificationProvider } from './contexts/NotificationContext' import { ProjectSyncProvider } from './contexts/ProjectSyncContext' import { ToastProvider } from './contexts/ToastContext' +import './i18n' import './index.css' +// Loading fallback for i18n +const LoadingFallback = () => ( +
+ Loading... +
+) + ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - - - - + }> + + + + + + + + + + + + , ) diff --git a/frontend/src/pages/AuditPage.tsx b/frontend/src/pages/AuditPage.tsx index 906b394..c34dfb1 100644 --- a/frontend/src/pages/AuditPage.tsx +++ b/frontend/src/pages/AuditPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { useAuth } from '../contexts/AuthContext' import { SkeletonTable } from '../components/Skeleton' import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit' @@ -280,6 +281,7 @@ function IntegrityVerificationModal({ result, isLoading, error, onClose }: Integ } export default function AuditPage() { + const { t } = useTranslation('audit') const { user } = useAuth() const [logs, setLogs] = useState([]) const [total, setTotal] = useState(0) @@ -466,15 +468,15 @@ export default function AuditPage() { if (!user?.is_system_admin) { return (
-

Access Denied

-

You need administrator privileges to view audit logs.

+

{t('common:messages.permissionDenied')}

+

{t('common:messages.permissionDenied')}

) } return (
-

Audit Logs

+

{t('title')}

{/* Filters */}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 74fc254..3d13ae3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' import { useAuth } from '../contexts/AuthContext' import { dashboardApi, DashboardResponse } from '../services/dashboard' import { @@ -10,6 +11,7 @@ import { import { Skeleton } from '../components/Skeleton' export default function Dashboard() { + const { t } = useTranslation('dashboard') const { user } = useAuth() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) @@ -22,7 +24,7 @@ export default function Dashboard() { const response = await dashboardApi.getDashboard() setData(response) } catch (err) { - setError('Failed to load dashboard data. Please try again.') + setError(t('common:messages.networkError')) console.error('Dashboard fetch error:', err) } finally { setLoading(false) @@ -93,19 +95,19 @@ export default function Dashboard() { return (
-

Welcome, {user?.name}!

+

{t('welcome', { name: user?.name })}

!
-

Unable to Load Dashboard

+

{t('common:messages.error')}

{error}

@@ -117,9 +119,9 @@ export default function Dashboard() {
{/* Welcome Section */}
-

Welcome, {user?.name}!

+

{t('welcome', { name: user?.name })}

- Here is your work overview for today + {t('sections.projectOverview')}

@@ -130,27 +132,27 @@ export default function Dashboard() { 0} /> 0} /> diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index c5c9f3f..5473ffb 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,8 +1,11 @@ import { useState, FormEvent } from 'react' import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { useAuth } from '../contexts/AuthContext' +import { LanguageSwitcher } from '../components/LanguageSwitcher' export default function Login() { + const { t } = useTranslation('auth') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') @@ -21,11 +24,11 @@ export default function Login() { } catch (err: unknown) { const error = err as { response?: { status?: number } } if (error.response?.status === 401) { - setError('Invalid email or password') + setError(t('errors.invalidCredentials')) } else if (error.response?.status === 503) { - setError('Authentication service temporarily unavailable') + setError(t('errors.loginFailed')) } else { - setError('An error occurred. Please try again.') + setError(t('errors.loginFailed')) } } finally { setLoading(false) @@ -34,16 +37,19 @@ export default function Login() { return (
+
+ +
-

Project Control

-

Sign in to your account

+

{t('welcome.title')}

+

{t('login.subtitle')}

{error &&
{error}
}
setEmail(e.target.value)} style={styles.input} className="login-input" - placeholder="your.email@company.com" + placeholder={t('login.emailPlaceholder')} required />
setPassword(e.target.value)} style={styles.input} className="login-input" - placeholder="Enter your password" + placeholder={t('login.passwordPlaceholder')} required />
@@ -78,7 +84,7 @@ export default function Login() { style={styles.button} disabled={loading} > - {loading ? 'Signing in...' : 'Sign in'} + {loading ? t('login.loggingIn') : t('login.submit')}
@@ -93,6 +99,12 @@ const styles: { [key: string]: React.CSSProperties } = { alignItems: 'center', minHeight: '100vh', backgroundColor: '#f5f5f5', + position: 'relative', + }, + languageSwitcherWrapper: { + position: 'absolute', + top: '20px', + right: '20px', }, card: { backgroundColor: 'white', diff --git a/frontend/src/pages/Spaces.tsx b/frontend/src/pages/Spaces.tsx index 3f718a7..41eb56b 100644 --- a/frontend/src/pages/Spaces.tsx +++ b/frontend/src/pages/Spaces.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import api from '../services/api' import { useToast } from '../contexts/ToastContext' import { SkeletonGrid } from '../components/Skeleton' @@ -15,6 +16,7 @@ interface Space { } export default function Spaces() { + const { t } = useTranslation('spaces') const navigate = useNavigate() const { showToast } = useToast() const [spaces, setSpaces] = useState([]) @@ -53,7 +55,7 @@ export default function Spaces() { setSpaces(response.data) } catch (err) { console.error('Failed to load spaces:', err) - showToast('Failed to load spaces', 'error') + showToast(t('common:messages.error'), 'error') } finally { setLoading(false) } @@ -68,10 +70,10 @@ export default function Spaces() { setShowCreateModal(false) setNewSpace({ name: '', description: '' }) loadSpaces() - showToast('Space created successfully', 'success') + showToast(t('messages.created'), 'success') } catch (err) { console.error('Failed to create space:', err) - showToast('Failed to create space', 'error') + showToast(t('common:messages.error'), 'error') } finally { setCreating(false) } @@ -92,9 +94,9 @@ export default function Spaces() { return (
-

Spaces

+

{t('title')}

@@ -112,21 +114,21 @@ export default function Spaces() { }} role="button" tabIndex={0} - aria-label={`Open space: ${space.name}`} + aria-label={`${t('title')}: ${space.name}`} >

{space.name}

- {space.description || 'No description'} + {space.description || t('common:labels.noData')}

- Owner: {space.owner_name || 'Unknown'} + {t('members.owner')}: {space.owner_name || t('common:labels.none')}
))} {spaces.length === 0 && (
-

No spaces yet. Create your first space to get started!

+

{t('empty.description')}

)}
@@ -141,25 +143,25 @@ export default function Spaces() { aria-labelledby="create-space-title" >
-

Create New Space

+

{t('createSpace')}

setNewSpace({ ...newSpace, name: e.target.value })} style={styles.input} autoFocus />