From 2172dbc483aee527bf36853acf1d2a6093a29e4c Mon Sep 17 00:00:00 2001 From: Ludo Date: Mon, 4 May 2026 16:32:22 +0200 Subject: [PATCH] feat: added FR and EN translation --- apps/web/src/App.css | 35 ++++++- apps/web/src/App.tsx | 29 +++--- apps/web/src/components/CreateTaskForm.tsx | 42 ++++---- apps/web/src/components/Timeline.tsx | 17 ++-- apps/web/src/components/TodaysTasks.tsx | 18 ++-- apps/web/src/i18n/LanguageContext.tsx | 32 +++++++ apps/web/src/i18n/translations.ts | 106 +++++++++++++++++++++ apps/web/src/main.tsx | 5 +- 8 files changed, 231 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/i18n/LanguageContext.tsx create mode 100644 apps/web/src/i18n/translations.ts diff --git a/apps/web/src/App.css b/apps/web/src/App.css index 64ff49e..1e1b248 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -11,11 +11,36 @@ main { } } +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 0 8px 0; +} + +.lang-toggle { + background: none; + border: 1px solid #ccc; + border-radius: 4px; + padding: 4px 10px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + color: #555; + letter-spacing: 0.5px; + transition: border-color 0.15s, color 0.15s; +} + +.lang-toggle:hover { + border-color: #111; + color: #111; +} + h1 { font-size: 28px; font-weight: 700; color: #111; - margin: 0 0 8px 0; + margin: 0; letter-spacing: -0.5px; } @@ -33,14 +58,14 @@ h1 { margin: 0 0 16px 0; padding-bottom: 10px; border-bottom: 1px solid #e8e8e8; + display: flex; + align-items: center; + user-select: none; + gap: 10px; } .section-title--clickable { cursor: pointer; - display: flex; - align-items: center; - gap: 10px; - user-select: none; } .section-title--clickable:hover { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8da9ff3..534474c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,12 +5,12 @@ import TodaysTasks from './components/TodaysTasks' import CreateTaskForm from './components/CreateTaskForm' import type { Task, NewTaskInput } from './types' import { isTaskDueToday } from './utils/taskUtils' - -const APP_NAME = 'TODO' +import { useLanguage } from './i18n/LanguageContext' type ApiStatus = 'loading' | 'ok' | 'error' function App() { + const { t, toggleLocale } = useLanguage() const [apiStatus, setApiStatus] = useState('loading') const [tasks, setTasks] = useState([]) const [loading, setLoading] = useState(true) @@ -52,14 +52,14 @@ function App() { if (!response.ok) { const errorData = await response.json() - setError(errorData.error || 'Failed to create task') + setError(errorData.error || t.errorCreateTask) return } await fetchTasks() } catch (error) { console.error('Error creating task:', error) - setError('Failed to create task') + setError(t.errorCreateTask) } } @@ -68,13 +68,13 @@ function App() { try { const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }) if (!response.ok) { - setError('Failed to delete task') + setError(t.errorDeleteTask) return } await fetchTasks() } catch (error) { console.error('Error deleting task:', error) - setError('Failed to delete task') + setError(t.errorDeleteTask) } } @@ -86,13 +86,13 @@ function App() { }) if (!response.ok) { - setError('Failed to complete task') + setError(t.errorCompleteTask) return } await fetchTasks() } catch (error) { console.error('Error completing task:', error) - setError('Failed to complete task') + setError(t.errorCompleteTask) } } @@ -100,12 +100,15 @@ function App() { return (
-

{APP_NAME}

+
+

{t.appName}

+ +
- {apiStatus === 'loading' && 'Connecting to API…'} - {apiStatus === 'ok' && '✓ API & database connected'} - {apiStatus === 'error' && '⚠️ Could not reach API'} + {apiStatus === 'loading' && t.statusLoading} + {apiStatus === 'ok' && t.statusOk} + {apiStatus === 'error' && t.statusError}
{error && ( @@ -116,7 +119,7 @@ function App() { )} {loading ? ( -

Loading tasks...

+

{t.loadingTasks}

) : ( <> {!isOpen ? ( ) : (
-

Create New Task

+

{t.createNewTaskTitle}

- + setName(e.target.value.slice(0, 60))} - placeholder="task name" + placeholder={t.taskPlaceholder} maxLength={60} autoFocus /> @@ -64,7 +66,7 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
- +
{doesRepeat && (
- +
setRepeatUnit(e.target.value as any)} className="repeat-unit" > - - - - + + + +
)}
- +
- +
- +
diff --git a/apps/web/src/components/Timeline.tsx b/apps/web/src/components/Timeline.tsx index f820a8d..d80f963 100644 --- a/apps/web/src/components/Timeline.tsx +++ b/apps/web/src/components/Timeline.tsx @@ -1,5 +1,6 @@ import type { Task } from '../types'; import { calculateNextDueDate } from '../utils/taskUtils'; +import { useLanguage } from '../i18n/LanguageContext'; import './Timeline.css'; interface TimelineProps { @@ -10,6 +11,7 @@ interface TimelineProps { const MAX_VISIBLE = 5; export default function Timeline({ tasks, onDeleteTask }: TimelineProps) { + const { t, locale } = useLanguage(); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -29,16 +31,16 @@ export default function Timeline({ tasks, onDeleteTask }: TimelineProps) { const formatDueDate = (date: Date): string => { const diff = Math.ceil((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); - if (diff === 1) return 'Tomorrow'; - if (diff <= 7) return `In ${diff} days`; - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + if (diff === 1) return t.tomorrow; + if (diff <= 7) return t.inDays(diff); + return date.toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', { month: 'short', day: 'numeric' }); }; return (
-

What the future holds

+

{t.whatTheFutureHolds}

{upcomingItems.length === 0 ? ( -

No upcoming tasks scheduled

+

{t.noUpcoming}

) : (
{upcomingItems.map(({ task, dueDate }) => ( @@ -57,16 +59,17 @@ interface UpcomingRowProps { } function UpcomingRow({ task, dueLabel, onDelete }: UpcomingRowProps) { + const { t } = useLanguage(); return (
{task.name} - {task.priority === 'ESSENTIAL' ? 'Essential' : 'When I have time'} + {task.priority === 'ESSENTIAL' ? t.essential : t.whenIHaveTime} {dueLabel} - +
); } diff --git a/apps/web/src/components/TodaysTasks.tsx b/apps/web/src/components/TodaysTasks.tsx index 5ff5766..9d1bcf6 100644 --- a/apps/web/src/components/TodaysTasks.tsx +++ b/apps/web/src/components/TodaysTasks.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { Task } from '../types'; +import { useLanguage } from '../i18n/LanguageContext'; import './TodaysTasks.css'; interface TodaysTasksProps { @@ -8,17 +9,19 @@ interface TodaysTasksProps { } export default function TodaysTasks({ tasks, onComplete }: TodaysTasksProps) { - const essentialTasks = tasks.filter(t => t.priority === 'ESSENTIAL'); - const whenIHaveTimeTasks = tasks.filter(t => t.priority === 'WHEN_I_HAVE_TIME'); + const { t } = useLanguage(); + const essentialTasks = tasks.filter(task => task.priority === 'ESSENTIAL'); + const whenIHaveTimeTasks = tasks.filter(task => task.priority === 'WHEN_I_HAVE_TIME'); const [showWhenIHaveTime, setShowWhenIHaveTime] = useState(false); return (
-

Need to do

+

{t.needToDo} + {essentialTasks.length}

{essentialTasks.length === 0 ? ( -

Nothing essential for today

+

{t.nothingEssential}

) : (
{essentialTasks.map(task => ( @@ -37,13 +40,13 @@ export default function TodaysTasks({ tasks, onComplete }: TodaysTasksProps) { className="section-title section-title--clickable" onClick={() => setShowWhenIHaveTime(!showWhenIHaveTime)} > - When I have time + {t.whenIHaveTime} {whenIHaveTimeTasks.length} {showWhenIHaveTime ? '▾' : '▸'} {showWhenIHaveTime && ( whenIHaveTimeTasks.length === 0 ? ( -

Nothing here

+

{t.nothingHere}

) : (
{whenIHaveTimeTasks.map(task => ( @@ -67,6 +70,7 @@ interface TaskCardProps { } function TaskCard({ task, onComplete }: TaskCardProps) { + const { t } = useLanguage(); const [tooltipVisible, setTooltipVisible] = useState(false); return ( @@ -84,7 +88,7 @@ function TaskCard({ task, onComplete }: TaskCardProps) { diff --git a/apps/web/src/i18n/LanguageContext.tsx b/apps/web/src/i18n/LanguageContext.tsx new file mode 100644 index 0000000..f400749 --- /dev/null +++ b/apps/web/src/i18n/LanguageContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useState } from 'react'; +import type { ReactNode } from 'react'; +import { translations } from './translations'; +import type { Locale, Translations } from './translations'; + +interface LanguageContextType { + locale: Locale; + t: Translations; + toggleLocale: () => void; +} + +const LanguageContext = createContext({ + locale: 'en', + t: translations.en, + toggleLocale: () => {}, +}); + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [locale, setLocale] = useState('en'); + + const toggleLocale = () => setLocale(l => (l === 'en' ? 'fr' : 'en')); + + return ( + + {children} + + ); +} + +export function useLanguage() { + return useContext(LanguageContext); +} diff --git a/apps/web/src/i18n/translations.ts b/apps/web/src/i18n/translations.ts new file mode 100644 index 0000000..0b3742d --- /dev/null +++ b/apps/web/src/i18n/translations.ts @@ -0,0 +1,106 @@ +export type Locale = 'en' | 'fr'; + +export const translations = { + en: { + appName: 'TODO', + // API status + statusLoading: 'Connecting to API…', + statusOk: '✓ API & database connected', + statusError: '⚠️ Could not reach API', + loadingTasks: 'Loading tasks…', + // Errors + errorCreateTask: 'Failed to create task', + errorDeleteTask: 'Failed to delete task', + errorCompleteTask: 'Failed to complete task', + // TodaysTasks + needToDo: 'Need to do', + nothingEssential: 'Nothing essential for today', + whenIHaveTime: 'When I have time', + nothingHere: 'Nothing here', + markCompleted: 'Mark as completed', + // Timeline + whatTheFutureHolds: 'What the future holds', + noUpcoming: 'No upcoming tasks scheduled', + tomorrow: 'Tomorrow', + inDays: (n: number) => `In ${n} days`, + removeTask: 'Remove task', + // Priority badges + essential: 'Essential', + // CreateTaskForm + createNewTask: '+ Create new task', + createNewTaskTitle: 'Create New Task', + taskLabel: 'Task', + taskPlaceholder: 'task name', + repeats: 'Repeats?', + yes: 'Yes', + no: 'No', + every: 'Every', + day: 'day', + days: 'days', + week: 'week', + weeks: 'weeks', + month: 'month', + months: 'months', + year: 'year', + years: 'years', + toDoRightNow: 'To do right now?', + priority: 'Priority', + whenIHaveTimePriority: 'When I have time', + createTask: 'Create Task', + cancel: 'Cancel', + // Lang toggle + switchLang: 'FR', + }, + fr: { + appName: 'TODO', + // API status + statusLoading: 'Connexion à l\'API…', + statusOk: '✓ API & base de données connectées', + statusError: '⚠️ Impossible de joindre l\'API', + loadingTasks: 'Chargement des tâches…', + // Errors + errorCreateTask: 'Impossible de créer la tâche', + errorDeleteTask: 'Impossible de supprimer la tâche', + errorCompleteTask: 'Impossible de marquer la tâche comme terminée', + // TodaysTasks + needToDo: 'À faire', + nothingEssential: 'Rien d\'essentiel pour aujourd\'hui', + whenIHaveTime: 'Quand j\'ai le temps', + nothingHere: 'Rien ici', + markCompleted: 'Marquer comme fait', + // Timeline + whatTheFutureHolds: 'Ce que l\'avenir réserve', + noUpcoming: 'Aucune tâche à venir', + tomorrow: 'Demain', + inDays: (n: number) => `Dans ${n} jours`, + removeTask: 'Supprimer la tâche', + // Priority badges + essential: 'Essentiel', + // CreateTaskForm + createNewTask: '+ Créer une tâche', + createNewTaskTitle: 'Créer une nouvelle tâche', + taskLabel: 'Tâche', + taskPlaceholder: 'nom de la tâche', + repeats: 'Se répète ?', + yes: 'Oui', + no: 'Non', + every: 'Tous les', + day: 'jour', + days: 'jours', + week: 'semaine', + weeks: 'semaines', + month: 'mois', + months: 'mois', + year: 'an', + years: 'ans', + toDoRightNow: 'À faire maintenant ?', + priority: 'Priorité', + whenIHaveTimePriority: 'Quand j\'ai le temps', + createTask: 'Créer la tâche', + cancel: 'Annuler', + // Lang toggle + switchLang: 'EN', + }, +} as const; + +export type Translations = typeof translations['en']; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index bef5202..5b6dcf8 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { LanguageProvider } from './i18n/LanguageContext.tsx' createRoot(document.getElementById('root')!).render( - + + + , )