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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user