feat: add i18n internationalization support

- Add react-i18next, i18next with browser language detection
- Support Traditional Chinese (zh-TW) and English (en)
- Default language: zh-TW, stored in localStorage
- Create 10 translation namespaces (common, auth, dashboard, tasks, etc.)
- Add LanguageSwitcher component in header
- Translate pages: Login, Dashboard, Tasks, Spaces, Workload, Audit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-08 23:18:41 +08:00
parent 1e31def7ba
commit 4bc3c24360
32 changed files with 1741 additions and 104 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import api from '../services/api'
import { KanbanBoard } from '../components/KanbanBoard'
import { CalendarView } from '../components/CalendarView'
@@ -64,6 +65,7 @@ const saveColumnVisibility = (projectId: string, visibility: Record<string, bool
}
export default function Tasks() {
const { t } = useTranslation('tasks')
const { projectId } = useParams()
const navigate = useNavigate()
const { subscribeToProject, unsubscribeFromProject, addTaskEventListener, isConnected } = useProjectSync()
@@ -472,14 +474,14 @@ export default function Tasks() {
<div style={styles.container}>
<div style={styles.breadcrumb}>
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
Spaces
{t('common:nav.spaces')}
</span>
<span style={styles.breadcrumbSeparator}>/</span>
<span
onClick={() => navigate(`/spaces/${project?.space_id}`)}
style={styles.breadcrumbLink}
>
Projects
{t('common:nav.projects')}
</span>
<span style={styles.breadcrumbSeparator}>/</span>
<span>{project?.title}</span>
@@ -487,13 +489,13 @@ export default function Tasks() {
<div style={styles.header}>
<div style={styles.titleContainer}>
<h1 style={styles.title}>Tasks</h1>
<h1 style={styles.title}>{t('title')}</h1>
{isConnected ? (
<span style={styles.liveIndicator} title="Real-time sync active">
<span style={styles.liveIndicator} title={t('common:labels.active')}>
Live
</span>
) : projectId ? (
<span style={styles.offlineIndicator} title="Real-time sync disconnected. Changes may not appear automatically.">
<span style={styles.offlineIndicator} title={t('common:labels.inactive')}>
Offline
</span>
) : null}
@@ -507,9 +509,9 @@ export default function Tasks() {
...styles.viewButton,
...(viewMode === 'list' ? styles.viewButtonActive : {}),
}}
aria-label="List view"
aria-label={t('views.list')}
>
List
{t('views.list')}
</button>
<button
onClick={() => setViewMode('kanban')}
@@ -517,9 +519,9 @@ export default function Tasks() {
...styles.viewButton,
...(viewMode === 'kanban' ? styles.viewButtonActive : {}),
}}
aria-label="Kanban view"
aria-label={t('views.kanban')}
>
Kanban
{t('views.kanban')}
</button>
<button
onClick={() => setViewMode('calendar')}
@@ -527,9 +529,9 @@ export default function Tasks() {
...styles.viewButton,
...(viewMode === 'calendar' ? styles.viewButtonActive : {}),
}}
aria-label="Calendar view"
aria-label={t('views.calendar')}
>
Calendar
{t('views.calendar')}
</button>
<button
onClick={() => setViewMode('gantt')}
@@ -537,9 +539,9 @@ export default function Tasks() {
...styles.viewButton,
...(viewMode === 'gantt' ? styles.viewButtonActive : {}),
}}
aria-label="Gantt view"
aria-label={t('views.gantt')}
>
Gantt
{t('views.gantt')}
</button>
</div>
{/* Column Visibility Toggle - only show when there are custom fields and in list view */}
@@ -548,13 +550,13 @@ export default function Tasks() {
<button
onClick={() => setShowColumnMenu(!showColumnMenu)}
style={styles.columnMenuButton}
aria-label="Toggle columns"
aria-label={t('settings:customFields.title')}
>
Columns
{t('settings:customFields.title')}
</button>
{showColumnMenu && (
<div style={styles.columnMenuDropdown}>
<div style={styles.columnMenuHeader}>Show Custom Fields</div>
<div style={styles.columnMenuHeader}>{t('settings:customFields.title')}</div>
{customFields.map((field) => (
<label key={field.id} style={styles.columnMenuItem}>
<input
@@ -567,7 +569,7 @@ export default function Tasks() {
</label>
))}
{customFields.length === 0 && (
<div style={styles.columnMenuEmpty}>No custom fields</div>
<div style={styles.columnMenuEmpty}>{t('common:labels.noData')}</div>
)}
</div>
)}
@@ -576,12 +578,12 @@ export default function Tasks() {
<button
onClick={() => navigate(`/projects/${projectId}/settings`)}
style={styles.settingsButton}
aria-label="Project settings"
aria-label={t('common:nav.settings')}
>
Settings
{t('common:nav.settings')}
</button>
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
+ New Task
+ {t('createTask')}
</button>
</div>
</div>
@@ -651,7 +653,7 @@ export default function Tasks() {
{tasks.length === 0 && (
<div style={styles.empty}>
<p>No tasks yet. Create your first task!</p>
<p>{t('empty.description')}</p>
</div>
)}
</div>
@@ -696,51 +698,51 @@ export default function Tasks() {
aria-labelledby="create-task-title"
>
<div style={styles.modal}>
<h2 id="create-task-title" style={styles.modalTitle}>Create New Task</h2>
<h2 id="create-task-title" style={styles.modalTitle}>{t('createTask')}</h2>
<label htmlFor="task-title" style={styles.visuallyHidden}>
Task title
{t('fields.title')}
</label>
<input
id="task-title"
type="text"
placeholder="Task title"
placeholder={t('fields.titlePlaceholder')}
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
style={styles.input}
autoFocus
/>
<label htmlFor="task-description" style={styles.visuallyHidden}>
Description
{t('fields.description')}
</label>
<textarea
id="task-description"
placeholder="Description (optional)"
placeholder={t('fields.descriptionPlaceholder')}
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
style={styles.textarea}
/>
<label style={styles.label}>Priority</label>
<label style={styles.label}>{t('fields.priority')}</label>
<select
value={newTask.priority}
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value })}
style={styles.select}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
<option value="low">{t('priority.low')}</option>
<option value="medium">{t('priority.medium')}</option>
<option value="high">{t('priority.high')}</option>
<option value="urgent">{t('priority.urgent')}</option>
</select>
<label style={styles.label}>Assignee</label>
<label style={styles.label}>{t('fields.assignee')}</label>
<UserSelect
value={newTask.assignee_id}
onChange={handleAssigneeChange}
placeholder="Select assignee..."
placeholder={t('common:labels.selectAssignee')}
/>
<div style={styles.fieldSpacer} />
<label style={styles.label}>Due Date</label>
<label style={styles.label}>{t('fields.dueDate')}</label>
<input
type="date"
value={newTask.due_date}
@@ -748,7 +750,7 @@ export default function Tasks() {
style={styles.input}
/>
<label style={styles.label}>Time Estimate (hours)</label>
<label style={styles.label}>{t('fields.estimatedHours')}</label>
<input
type="number"
min="0"
@@ -763,7 +765,7 @@ export default function Tasks() {
{customFields.filter((f) => f.field_type !== 'formula').length > 0 && (
<>
<div style={styles.customFieldsDivider} />
<div style={styles.customFieldsTitle}>Custom Fields</div>
<div style={styles.customFieldsTitle}>{t('settings:customFields.title')}</div>
{customFields
.filter((field) => field.field_type !== 'formula')
.map((field) => (
@@ -792,14 +794,14 @@ export default function Tasks() {
<div style={styles.modalActions}>
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
Cancel
{t('common:buttons.cancel')}
</button>
<button
onClick={handleCreateTask}
disabled={creating || !newTask.title.trim()}
style={styles.submitButton}
>
{creating ? 'Creating...' : 'Create'}
{creating ? t('common:labels.loading') : t('common:buttons.create')}
</button>
</div>
</div>