feat: added FR and EN translation
Build and Deploy / build-and-push (push) Failing after 32s Details

This commit is contained in:
Ludo 2026-05-04 16:32:22 +02:00
parent 9f03e5bc45
commit 2172dbc483
8 changed files with 231 additions and 53 deletions

View File

@ -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 { h1 {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: #111; color: #111;
margin: 0 0 8px 0; margin: 0;
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
@ -33,14 +58,14 @@ h1 {
margin: 0 0 16px 0; margin: 0 0 16px 0;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
user-select: none;
gap: 10px;
} }
.section-title--clickable { .section-title--clickable {
cursor: pointer; cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
user-select: none;
} }
.section-title--clickable:hover { .section-title--clickable:hover {

View File

@ -5,12 +5,12 @@ import TodaysTasks from './components/TodaysTasks'
import CreateTaskForm from './components/CreateTaskForm' import CreateTaskForm from './components/CreateTaskForm'
import type { Task, NewTaskInput } from './types' import type { Task, NewTaskInput } from './types'
import { isTaskDueToday } from './utils/taskUtils' import { isTaskDueToday } from './utils/taskUtils'
import { useLanguage } from './i18n/LanguageContext'
const APP_NAME = 'TODO'
type ApiStatus = 'loading' | 'ok' | 'error' type ApiStatus = 'loading' | 'ok' | 'error'
function App() { function App() {
const { t, toggleLocale } = useLanguage()
const [apiStatus, setApiStatus] = useState<ApiStatus>('loading') const [apiStatus, setApiStatus] = useState<ApiStatus>('loading')
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -52,14 +52,14 @@ function App() {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json() const errorData = await response.json()
setError(errorData.error || 'Failed to create task') setError(errorData.error || t.errorCreateTask)
return return
} }
await fetchTasks() await fetchTasks()
} catch (error) { } catch (error) {
console.error('Error creating task:', error) console.error('Error creating task:', error)
setError('Failed to create task') setError(t.errorCreateTask)
} }
} }
@ -68,13 +68,13 @@ function App() {
try { try {
const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }) const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' })
if (!response.ok) { if (!response.ok) {
setError('Failed to delete task') setError(t.errorDeleteTask)
return return
} }
await fetchTasks() await fetchTasks()
} catch (error) { } catch (error) {
console.error('Error deleting task:', error) console.error('Error deleting task:', error)
setError('Failed to delete task') setError(t.errorDeleteTask)
} }
} }
@ -86,13 +86,13 @@ function App() {
}) })
if (!response.ok) { if (!response.ok) {
setError('Failed to complete task') setError(t.errorCompleteTask)
return return
} }
await fetchTasks() await fetchTasks()
} catch (error) { } catch (error) {
console.error('Error completing task:', error) console.error('Error completing task:', error)
setError('Failed to complete task') setError(t.errorCompleteTask)
} }
} }
@ -100,12 +100,15 @@ function App() {
return ( return (
<main> <main>
<h1>{APP_NAME}</h1> <div className="app-header">
<h1>{t.appName}</h1>
<button className="lang-toggle" onClick={toggleLocale}>{t.switchLang}</button>
</div>
<div className={`status status--${apiStatus}`}> <div className={`status status--${apiStatus}`}>
{apiStatus === 'loading' && 'Connecting to API…'} {apiStatus === 'loading' && t.statusLoading}
{apiStatus === 'ok' && '✓ API & database connected'} {apiStatus === 'ok' && t.statusOk}
{apiStatus === 'error' && '⚠️ Could not reach API'} {apiStatus === 'error' && t.statusError}
</div> </div>
{error && ( {error && (
@ -116,7 +119,7 @@ function App() {
)} )}
{loading ? ( {loading ? (
<p>Loading tasks...</p> <p>{t.loadingTasks}</p>
) : ( ) : (
<> <>
<TodaysTasks <TodaysTasks

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import type { NewTaskInput } from '../types'; import type { NewTaskInput } from '../types';
import { useLanguage } from '../i18n/LanguageContext';
import './CreateTaskForm.css'; import './CreateTaskForm.css';
interface CreateTaskFormProps { interface CreateTaskFormProps {
@ -7,6 +8,7 @@ interface CreateTaskFormProps {
} }
export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) { export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [doesRepeat, setDoesRepeat] = useState(true); const [doesRepeat, setDoesRepeat] = useState(true);
@ -44,19 +46,19 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
<div className="create-task-form"> <div className="create-task-form">
{!isOpen ? ( {!isOpen ? (
<button className="open-form-btn" onClick={() => setIsOpen(true)}> <button className="open-form-btn" onClick={() => setIsOpen(true)}>
+ Create new task {t.createNewTask}
</button> </button>
) : ( ) : (
<form onSubmit={handleSubmit} className="task-form"> <form onSubmit={handleSubmit} className="task-form">
<h3>Create New Task</h3> <h3>{t.createNewTaskTitle}</h3>
<div className="form-group"> <div className="form-group">
<label>Task</label> <label>{t.taskLabel}</label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value.slice(0, 60))} onChange={(e) => setName(e.target.value.slice(0, 60))}
placeholder="task name" placeholder={t.taskPlaceholder}
maxLength={60} maxLength={60}
autoFocus autoFocus
/> />
@ -64,7 +66,7 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Repeats?</label> <label>{t.repeats}</label>
<div className="radio-group"> <div className="radio-group">
<label> <label>
<input <input
@ -72,7 +74,7 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
checked={doesRepeat} checked={doesRepeat}
onChange={() => setDoesRepeat(true)} onChange={() => setDoesRepeat(true)}
/> />
Yes {t.yes}
</label> </label>
<label> <label>
<input <input
@ -80,14 +82,14 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
checked={!doesRepeat} checked={!doesRepeat}
onChange={() => setDoesRepeat(false)} onChange={() => setDoesRepeat(false)}
/> />
No {t.no}
</label> </label>
</div> </div>
</div> </div>
{doesRepeat && ( {doesRepeat && (
<div className="form-group"> <div className="form-group">
<label>Every</label> <label>{t.every}</label>
<div className="repeat-input"> <div className="repeat-input">
<input <input
type="number" type="number"
@ -102,17 +104,17 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
onChange={(e) => setRepeatUnit(e.target.value as any)} onChange={(e) => setRepeatUnit(e.target.value as any)}
className="repeat-unit" className="repeat-unit"
> >
<option value="DAYS">day{repeatAmount !== '1' ? 's' : ''}</option> <option value="DAYS">{repeatAmount !== '1' ? t.days : t.day}</option>
<option value="WEEKS">week{repeatAmount !== '1' ? 's' : ''}</option> <option value="WEEKS">{repeatAmount !== '1' ? t.weeks : t.week}</option>
<option value="MONTHS">month{repeatAmount !== '1' ? 's' : ''}</option> <option value="MONTHS">{repeatAmount !== '1' ? t.months : t.month}</option>
<option value="YEARS">year{repeatAmount !== '1' ? 's' : ''}</option> <option value="YEARS">{repeatAmount !== '1' ? t.years : t.year}</option>
</select> </select>
</div> </div>
</div> </div>
)} )}
<div className="form-group"> <div className="form-group">
<label>To do right now?</label> <label>{t.toDoRightNow}</label>
<div className="radio-group"> <div className="radio-group">
<label> <label>
<input <input
@ -120,7 +122,7 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
checked={toDoToday} checked={toDoToday}
onChange={() => setToDoToday(true)} onChange={() => setToDoToday(true)}
/> />
Yes {t.yes}
</label> </label>
<label> <label>
<input <input
@ -128,13 +130,13 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
checked={!toDoToday} checked={!toDoToday}
onChange={() => setToDoToday(false)} onChange={() => setToDoToday(false)}
/> />
No {t.no}
</label> </label>
</div> </div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Priority</label> <label>{t.priority}</label>
<div className="radio-group"> <div className="radio-group">
<label> <label>
<input <input
@ -142,7 +144,7 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
checked={priority === 'ESSENTIAL'} checked={priority === 'ESSENTIAL'}
onChange={() => setPriority('ESSENTIAL')} onChange={() => setPriority('ESSENTIAL')}
/> />
Essential {t.essential}
</label> </label>
<label> <label>
<input <input
@ -150,15 +152,15 @@ export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) {
checked={priority === 'WHEN_I_HAVE_TIME'} checked={priority === 'WHEN_I_HAVE_TIME'}
onChange={() => setPriority('WHEN_I_HAVE_TIME')} onChange={() => setPriority('WHEN_I_HAVE_TIME')}
/> />
When I have time {t.whenIHaveTimePriority}
</label> </label>
</div> </div>
</div> </div>
<div className="form-actions"> <div className="form-actions">
<button type="submit" className="submit-btn">Create Task</button> <button type="submit" className="submit-btn">{t.createTask}</button>
<button type="button" className="cancel-btn" onClick={() => setIsOpen(false)}> <button type="button" className="cancel-btn" onClick={() => setIsOpen(false)}>
Cancel {t.cancel}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,5 +1,6 @@
import type { Task } from '../types'; import type { Task } from '../types';
import { calculateNextDueDate } from '../utils/taskUtils'; import { calculateNextDueDate } from '../utils/taskUtils';
import { useLanguage } from '../i18n/LanguageContext';
import './Timeline.css'; import './Timeline.css';
interface TimelineProps { interface TimelineProps {
@ -10,6 +11,7 @@ interface TimelineProps {
const MAX_VISIBLE = 5; const MAX_VISIBLE = 5;
export default function Timeline({ tasks, onDeleteTask }: TimelineProps) { export default function Timeline({ tasks, onDeleteTask }: TimelineProps) {
const { t, locale } = useLanguage();
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
@ -29,16 +31,16 @@ export default function Timeline({ tasks, onDeleteTask }: TimelineProps) {
const formatDueDate = (date: Date): string => { const formatDueDate = (date: Date): string => {
const diff = Math.ceil((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); const diff = Math.ceil((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diff === 1) return 'Tomorrow'; if (diff === 1) return t.tomorrow;
if (diff <= 7) return `In ${diff} days`; if (diff <= 7) return t.inDays(diff);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return date.toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', { month: 'short', day: 'numeric' });
}; };
return ( return (
<section className="upcoming-section"> <section className="upcoming-section">
<h2 className="section-title">What the future holds</h2> <h2 className="section-title">{t.whatTheFutureHolds}</h2>
{upcomingItems.length === 0 ? ( {upcomingItems.length === 0 ? (
<p className="no-upcoming">No upcoming tasks scheduled</p> <p className="no-upcoming">{t.noUpcoming}</p>
) : ( ) : (
<div className={`upcoming-list${hasMore ? ' upcoming-list--scrollable' : ''}`}> <div className={`upcoming-list${hasMore ? ' upcoming-list--scrollable' : ''}`}>
{upcomingItems.map(({ task, dueDate }) => ( {upcomingItems.map(({ task, dueDate }) => (
@ -57,16 +59,17 @@ interface UpcomingRowProps {
} }
function UpcomingRow({ task, dueLabel, onDelete }: UpcomingRowProps) { function UpcomingRow({ task, dueLabel, onDelete }: UpcomingRowProps) {
const { t } = useLanguage();
return ( return (
<div className="upcoming-row"> <div className="upcoming-row">
<span className="upcoming-name"> <span className="upcoming-name">
<span className="upcoming-name-text">{task.name}</span> <span className="upcoming-name-text">{task.name}</span>
</span> </span>
<span className="upcoming-badge"> <span className="upcoming-badge">
{task.priority === 'ESSENTIAL' ? 'Essential' : 'When I have time'} {task.priority === 'ESSENTIAL' ? t.essential : t.whenIHaveTime}
</span> </span>
<span className="upcoming-due">{dueLabel}</span> <span className="upcoming-due">{dueLabel}</span>
<button className="upcoming-delete" onClick={onDelete} title="Remove task"></button> <button className="upcoming-delete" onClick={onDelete} title={t.removeTask}></button>
</div> </div>
); );
} }

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Task } from '../types'; import type { Task } from '../types';
import { useLanguage } from '../i18n/LanguageContext';
import './TodaysTasks.css'; import './TodaysTasks.css';
interface TodaysTasksProps { interface TodaysTasksProps {
@ -8,17 +9,19 @@ interface TodaysTasksProps {
} }
export default function TodaysTasks({ tasks, onComplete }: TodaysTasksProps) { export default function TodaysTasks({ tasks, onComplete }: TodaysTasksProps) {
const essentialTasks = tasks.filter(t => t.priority === 'ESSENTIAL'); const { t } = useLanguage();
const whenIHaveTimeTasks = tasks.filter(t => t.priority === 'WHEN_I_HAVE_TIME'); const essentialTasks = tasks.filter(task => task.priority === 'ESSENTIAL');
const whenIHaveTimeTasks = tasks.filter(task => task.priority === 'WHEN_I_HAVE_TIME');
const [showWhenIHaveTime, setShowWhenIHaveTime] = useState(false); const [showWhenIHaveTime, setShowWhenIHaveTime] = useState(false);
return ( return (
<div className="todays-tasks"> <div className="todays-tasks">
<section className="task-section"> <section className="task-section">
<h2 className="section-title">Need to do</h2> <h2 className="section-title">{t.needToDo}
<span className="count-badge">{essentialTasks.length}</span></h2>
{essentialTasks.length === 0 ? ( {essentialTasks.length === 0 ? (
<p className="no-tasks">Nothing essential for today</p> <p className="no-tasks">{t.nothingEssential}</p>
) : ( ) : (
<div className="task-grid"> <div className="task-grid">
{essentialTasks.map(task => ( {essentialTasks.map(task => (
@ -37,13 +40,13 @@ export default function TodaysTasks({ tasks, onComplete }: TodaysTasksProps) {
className="section-title section-title--clickable" className="section-title section-title--clickable"
onClick={() => setShowWhenIHaveTime(!showWhenIHaveTime)} onClick={() => setShowWhenIHaveTime(!showWhenIHaveTime)}
> >
When I have time {t.whenIHaveTime}
<span className="count-badge">{whenIHaveTimeTasks.length}</span> <span className="count-badge">{whenIHaveTimeTasks.length}</span>
<span className="toggle-icon">{showWhenIHaveTime ? '▾' : '▸'}</span> <span className="toggle-icon">{showWhenIHaveTime ? '▾' : '▸'}</span>
</h2> </h2>
{showWhenIHaveTime && ( {showWhenIHaveTime && (
whenIHaveTimeTasks.length === 0 ? ( whenIHaveTimeTasks.length === 0 ? (
<p className="no-tasks">Nothing here</p> <p className="no-tasks">{t.nothingHere}</p>
) : ( ) : (
<div className="task-grid"> <div className="task-grid">
{whenIHaveTimeTasks.map(task => ( {whenIHaveTimeTasks.map(task => (
@ -67,6 +70,7 @@ interface TaskCardProps {
} }
function TaskCard({ task, onComplete }: TaskCardProps) { function TaskCard({ task, onComplete }: TaskCardProps) {
const { t } = useLanguage();
const [tooltipVisible, setTooltipVisible] = useState(false); const [tooltipVisible, setTooltipVisible] = useState(false);
return ( return (
@ -84,7 +88,7 @@ function TaskCard({ task, onComplete }: TaskCardProps) {
<button <button
className="action-btn" className="action-btn"
onClick={() => onComplete(task.id)} onClick={() => onComplete(task.id)}
title="Mark as completed" title={t.markCompleted}
> >
</button> </button>

View File

@ -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<LanguageContextType>({
locale: 'en',
t: translations.en,
toggleLocale: () => {},
});
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState<Locale>('en');
const toggleLocale = () => setLocale(l => (l === 'en' ? 'fr' : 'en'));
return (
<LanguageContext.Provider value={{ locale, t: translations[locale], toggleLocale }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
return useContext(LanguageContext);
}

View File

@ -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'];

View File

@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { LanguageProvider } from './i18n/LanguageContext.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <LanguageProvider>
<App />
</LanguageProvider>
</StrictMode>, </StrictMode>,
) )