From 17f2131a52394f0a515cdbfd36c2d22fca3f0ddf Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sat, 14 Mar 2026 16:06:12 +0100 Subject: [PATCH 1/4] feat(i18n): add EN/FR translations, article pagination, and favicon - Add react-i18next with EN/FR locale files and localStorage persistence - Add sidebar language switcher in user row (collapsed: vertical stack) - Replace Signalist text with favicon logo when sidebar is collapsed - Add signal-wave SVG favicon and update browser tab title - Add article pagination (20/page) with numbered MUI Pagination - Add paginated article response envelope (items, total, page, pages) - Fix Behat ApiContext to handle paginated response shape Co-Authored-By: Claude Sonnet 4.6 --- frontend/index.html | 4 +- frontend/package-lock.json | 89 +++++++++- frontend/package.json | 2 + frontend/public/favicon.svg | 6 + frontend/src/api/articles.ts | 14 +- .../src/components/Article/ArticleCard.tsx | 23 ++- .../src/components/Article/ArticleList.tsx | 39 +++-- frontend/src/components/Article/SearchBar.tsx | 6 +- .../src/components/Bookmark/BookmarkList.tsx | 31 ++-- .../components/Category/CategoryDialog.tsx | 24 ++- frontend/src/components/Common/ErrorAlert.tsx | 9 +- .../src/components/Common/LoadingSpinner.tsx | 7 +- .../src/components/Feed/AddFeedDialog.tsx | 20 ++- .../src/components/Feed/EditFeedDialog.tsx | 22 +-- .../src/components/Feed/FeedStatusChip.tsx | 9 +- frontend/src/components/Layout/Sidebar.tsx | 86 +++++++-- frontend/src/hooks/useArticles.ts | 10 +- frontend/src/i18n/index.ts | 20 +++ frontend/src/i18n/locales/en.json | 163 ++++++++++++++++++ frontend/src/i18n/locales/fr.json | 163 ++++++++++++++++++ frontend/src/main.tsx | 1 + frontend/src/pages/ArticlePage.tsx | 35 ++-- frontend/src/pages/BookmarksPage.tsx | 9 +- frontend/src/pages/CategoryPage.tsx | 36 ++-- frontend/src/pages/CheckEmailPage.tsx | 18 +- frontend/src/pages/Dashboard.tsx | 28 +-- frontend/src/pages/FeedManagementPage.tsx | 43 +++-- frontend/src/pages/LoginPage.tsx | 18 +- frontend/src/pages/RegisterPage.tsx | 22 +-- frontend/src/pages/VerifyEmailPage.tsx | 22 +-- frontend/src/theme.ts | 7 + frontend/src/types/index.ts | 10 ++ .../Article/Handler/ListArticlesHandler.php | 21 ++- .../Port/ArticleRepositoryInterface.php | 7 +- .../Article/Query/ListArticlesQuery.php | 2 + .../Article/Query/PaginatedArticlesResult.php | 22 +++ .../Resource/PaginatedArticlesResponse.php | 20 +++ .../State/ArticleStateProvider.php | 25 ++- .../Article/DoctrineArticleRepository.php | 23 ++- tests/Behat/ApiContext.php | 28 ++- .../Handler/ListArticlesHandlerTest.php | 85 +++++++-- 41 files changed, 1002 insertions(+), 227 deletions(-) create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en.json create mode 100644 frontend/src/i18n/locales/fr.json create mode 100644 src/Domain/Article/Query/PaginatedArticlesResult.php create mode 100644 src/Infrastructure/ApiPlatform/Resource/PaginatedArticlesResponse.php diff --git a/frontend/index.html b/frontend/index.html index 072a57e..0770f91 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - frontend + Signalist
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 29f027c..0a0e4a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,8 +14,10 @@ "@mui/material": "^7.3.7", "@tanstack/react-query": "^5.90.20", "axios": "^1.13.4", + "i18next": "^25.8.18", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^16.5.8", "react-router-dom": "^7.13.0" }, "devDependencies": { @@ -4009,6 +4011,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", @@ -4053,6 +4064,37 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "25.8.18", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", + "integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==", + "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", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4939,6 +4981,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "16.5.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz", + "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", + "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": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", @@ -5525,7 +5594,7 @@ "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", "bin": { "tsc": "bin/tsc", @@ -5607,6 +5676,15 @@ "punycode": "^2.1.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": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -5760,6 +5838,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 6d56b72..9cfe212 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,8 +24,10 @@ "@mui/material": "^7.3.7", "@tanstack/react-query": "^5.90.20", "axios": "^1.13.4", + "i18next": "^25.8.18", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^16.5.8", "react-router-dom": "^7.13.0" }, "devDependencies": { diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..d08c52b --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/api/articles.ts b/frontend/src/api/articles.ts index ef6cad7..9460e60 100644 --- a/frontend/src/api/articles.ts +++ b/frontend/src/api/articles.ts @@ -1,14 +1,16 @@ import apiClient from './client'; -import type { Article } from '../types'; +import type { Article, PaginatedArticles } from '../types'; export interface ArticleFilters { feedId?: string; categoryId?: string; isRead?: boolean; search?: string; + page?: number; + limit?: number; } -export async function getArticles(filters?: ArticleFilters): Promise { +export async function getArticles(filters?: ArticleFilters): Promise { const params = new URLSearchParams(); if (filters?.feedId) { @@ -23,11 +25,17 @@ export async function getArticles(filters?: ArticleFilters): Promise if (filters?.search) { params.append('search', filters.search); } + if (filters?.page) { + params.append('page', String(filters.page)); + } + if (filters?.limit) { + params.append('limit', String(filters.limit)); + } const queryString = params.toString(); const url = queryString ? `/articles?${queryString}` : '/articles'; - const response = await apiClient.get(url); + const response = await apiClient.get(url); return response.data; } diff --git a/frontend/src/components/Article/ArticleCard.tsx b/frontend/src/components/Article/ArticleCard.tsx index 3319c01..b18bd4c 100644 --- a/frontend/src/components/Article/ArticleCard.tsx +++ b/frontend/src/components/Article/ArticleCard.tsx @@ -9,6 +9,7 @@ import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; import BookmarkIcon from '@mui/icons-material/Bookmark'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import { useTranslation } from 'react-i18next'; import type { Article } from '../../types'; interface ArticleCardProps { @@ -25,14 +26,18 @@ export default function ArticleCard({ onToggleBookmark, }: ArticleCardProps) { const navigate = useNavigate(); + const { t, i18n } = useTranslation(); const formatDate = (dateString: string | null) => { if (!dateString) return ''; - return new Date(dateString).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + return new Date(dateString).toLocaleDateString( + i18n.language === 'fr' ? 'fr-FR' : 'en-US', + { + month: 'short', + day: 'numeric', + year: 'numeric', + } + ); }; return ( @@ -57,7 +62,7 @@ export default function ArticleCard({ }, }} > - + - + { e.stopPropagation(); onToggleRead(article.id, article.isRead); }} @@ -132,7 +137,7 @@ export default function ArticleCard({ {article.isRead ? : } - + { e.stopPropagation(); onToggleBookmark(article.id, isBookmarked); }} @@ -141,7 +146,7 @@ export default function ArticleCard({ {isBookmarked ? : } - + void; onToggleRead: (id: string, isRead: boolean) => void; onToggleBookmark: (id: string, isBookmarked: boolean) => void; + onPageChange: (page: number) => void; emptyMessage?: string; } export default function ArticleList({ - articles, + data, bookmarks, isLoading, isError, @@ -27,32 +30,36 @@ export default function ArticleList({ onRefetch, onToggleRead, onToggleBookmark, - emptyMessage = 'No articles found', + onPageChange, + emptyMessage, }: ArticleListProps) { + const { t } = useTranslation(); const bookmarkedArticleIds = new Set( bookmarks?.map((b) => b.articleId) || [] ); if (isLoading) { - return ; + return ; } if (isError) { return ( ); } - if (!articles || articles.length === 0) { + const articles: Article[] = data?.items ?? []; + + if (articles.length === 0) { return ( } - title={emptyMessage} - description="Articles will appear here once feeds are crawled" + title={emptyMessage ?? t('articleList.noArticles')} + description={t('articleList.willAppear')} /> ); } @@ -68,6 +75,18 @@ export default function ArticleList({ onToggleBookmark={onToggleBookmark} /> ))} + + {data && data.pages > 1 && ( + + onPageChange(page)} + color="primary" + shape="rounded" + /> + + )} ); } diff --git a/frontend/src/components/Article/SearchBar.tsx b/frontend/src/components/Article/SearchBar.tsx index 320fbd2..0c18148 100644 --- a/frontend/src/components/Article/SearchBar.tsx +++ b/frontend/src/components/Article/SearchBar.tsx @@ -4,6 +4,7 @@ import InputAdornment from '@mui/material/InputAdornment'; import IconButton from '@mui/material/IconButton'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; +import { useTranslation } from 'react-i18next'; interface SearchBarProps { value: string; @@ -14,8 +15,9 @@ interface SearchBarProps { export default function SearchBar({ value, onChange, - placeholder = 'Search articles...', + placeholder, }: SearchBarProps) { + const { t } = useTranslation(); const [localValue, setLocalValue] = useState(value); const debounceRef = useRef | null>(null); @@ -52,7 +54,7 @@ export default function SearchBar({ handleChange(e.target.value)} - placeholder={placeholder} + placeholder={placeholder ?? t('searchBar.placeholder')} size="small" fullWidth slotProps={{ diff --git a/frontend/src/components/Bookmark/BookmarkList.tsx b/frontend/src/components/Bookmark/BookmarkList.tsx index 64c55af..8a3d20d 100644 --- a/frontend/src/components/Bookmark/BookmarkList.tsx +++ b/frontend/src/components/Bookmark/BookmarkList.tsx @@ -10,6 +10,7 @@ import Box from '@mui/material/Box'; import Tooltip from '@mui/material/Tooltip'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import BookmarkIcon from '@mui/icons-material/Bookmark'; +import { useTranslation } from 'react-i18next'; import LoadingSpinner from '../Common/LoadingSpinner'; import ErrorAlert from '../Common/ErrorAlert'; import EmptyState from '../Common/EmptyState'; @@ -33,24 +34,28 @@ export default function BookmarkList({ onDelete, }: BookmarkListProps) { const navigate = useNavigate(); + const { t, i18n } = useTranslation(); const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + return new Date(dateString).toLocaleDateString( + i18n.language === 'fr' ? 'fr-FR' : 'en-US', + { + month: 'short', + day: 'numeric', + year: 'numeric', + } + ); }; if (isLoading) { - return ; + return ; } if (isError) { return ( ); @@ -60,8 +65,8 @@ export default function BookmarkList({ return ( } - title="No bookmarks yet" - description="Bookmark articles to save them for later" + title={t('bookmarks.noBookmarks')} + description={t('bookmarks.saveForLater')} /> ); } @@ -104,13 +109,13 @@ export default function BookmarkList({ )} - Bookmarked on {formatDate(bookmark.createdAt)} + {t('bookmarks.bookmarkedOn', { date: formatDate(bookmark.createdAt) })} } /> - + - + onDelete(bookmark.id)} diff --git a/frontend/src/components/Category/CategoryDialog.tsx b/frontend/src/components/Category/CategoryDialog.tsx index 7b8c5d4..174954f 100644 --- a/frontend/src/components/Category/CategoryDialog.tsx +++ b/frontend/src/components/Category/CategoryDialog.tsx @@ -6,6 +6,7 @@ import DialogActions from '@mui/material/DialogActions'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; +import { useTranslation } from 'react-i18next'; import type { Category, CreateCategoryInput } from '../../types'; interface CategoryDialogProps { @@ -23,6 +24,7 @@ export default function CategoryDialog({ category, isLoading = false, }: CategoryDialogProps) { + const { t } = useTranslation(); const [name, setName] = useState(''); const [slug, setSlug] = useState(''); const [description, setDescription] = useState(''); @@ -30,6 +32,7 @@ export default function CategoryDialog({ useEffect(() => { if (category) { + // eslint-disable-next-line react-hooks/set-state-in-effect setName(category.name); setSlug(category.slug); setDescription(category.description || ''); @@ -45,7 +48,6 @@ export default function CategoryDialog({ const handleNameChange = (value: string) => { setName(value); if (!category) { - // Auto-generate slug for new categories setSlug( value .toLowerCase() @@ -69,12 +71,12 @@ export default function CategoryDialog({
- {category ? 'Edit Category' : 'Add Category'} + {category ? t('categoryDialog.editTitle') : t('categoryDialog.addTitle')} handleNameChange(e.target.value)} required @@ -82,15 +84,15 @@ export default function CategoryDialog({ autoFocus /> setSlug(e.target.value)} required fullWidth - helperText="URL-friendly identifier" + helperText={t('categoryDialog.slugHelper')} /> setDescription(e.target.value)} fullWidth @@ -99,7 +101,7 @@ export default function CategoryDialog({ /> setColor(e.target.value)} @@ -121,10 +123,14 @@ export default function CategoryDialog({
diff --git a/frontend/src/components/Common/ErrorAlert.tsx b/frontend/src/components/Common/ErrorAlert.tsx index 70637a5..1d5e096 100644 --- a/frontend/src/components/Common/ErrorAlert.tsx +++ b/frontend/src/components/Common/ErrorAlert.tsx @@ -2,6 +2,7 @@ import Alert from '@mui/material/Alert'; import AlertTitle from '@mui/material/AlertTitle'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; +import { useTranslation } from 'react-i18next'; interface ErrorAlertProps { title?: string; @@ -10,10 +11,12 @@ interface ErrorAlertProps { } export default function ErrorAlert({ - title = 'Error', + title, message, onRetry, }: ErrorAlertProps) { + const { t } = useTranslation(); + return ( - Retry + {t('common.retry')} ) } > - {title} + {title ?? t('common.error')} {message} diff --git a/frontend/src/components/Common/LoadingSpinner.tsx b/frontend/src/components/Common/LoadingSpinner.tsx index 918ee44..43d334c 100644 --- a/frontend/src/components/Common/LoadingSpinner.tsx +++ b/frontend/src/components/Common/LoadingSpinner.tsx @@ -1,12 +1,15 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; interface LoadingSpinnerProps { message?: string; } -export default function LoadingSpinner({ message = 'Loading...' }: LoadingSpinnerProps) { +export default function LoadingSpinner({ message }: LoadingSpinnerProps) { + const { t } = useTranslation(); + return ( - {message} + {message ?? t('common.loading')} ); diff --git a/frontend/src/components/Feed/AddFeedDialog.tsx b/frontend/src/components/Feed/AddFeedDialog.tsx index b06f231..b0e7208 100644 --- a/frontend/src/components/Feed/AddFeedDialog.tsx +++ b/frontend/src/components/Feed/AddFeedDialog.tsx @@ -10,6 +10,7 @@ import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; import Select from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; +import { useTranslation } from 'react-i18next'; import type { Category, AddFeedInput } from '../../types'; interface AddFeedDialogProps { @@ -27,6 +28,7 @@ export default function AddFeedDialog({ categories, isLoading = false, }: AddFeedDialogProps) { + const { t } = useTranslation(); const [url, setUrl] = useState(''); const [title, setTitle] = useState(''); const [categoryId, setCategoryId] = useState(''); @@ -50,31 +52,31 @@ export default function AddFeedDialog({ return (
- Add Feed + {t('addFeedDialog.title')} setUrl(e.target.value)} required fullWidth autoFocus - placeholder="https://example.com/feed.xml" + placeholder={t('addFeedDialog.urlPlaceholder')} type="url" /> setTitle(e.target.value)} fullWidth - helperText="Leave empty to auto-detect from feed" + helperText={t('addFeedDialog.titleHelper')} /> - Category + {t('addFeedDialog.categoryLabel')} setCategoryId(e.target.value)} > {categories.map((category) => ( @@ -69,24 +71,24 @@ export default function EditFeedDialog({ - Status + {t('editFeedDialog.statusLabel')}
diff --git a/frontend/src/components/Feed/FeedStatusChip.tsx b/frontend/src/components/Feed/FeedStatusChip.tsx index cde81ff..73c4977 100644 --- a/frontend/src/components/Feed/FeedStatusChip.tsx +++ b/frontend/src/components/Feed/FeedStatusChip.tsx @@ -1,14 +1,17 @@ import Chip from '@mui/material/Chip'; +import { useTranslation } from 'react-i18next'; interface FeedStatusChipProps { status: 'active' | 'paused' | 'error' | string; } export default function FeedStatusChip({ status }: FeedStatusChipProps) { + const { t } = useTranslation(); + const config = { - active: { label: 'Active', color: 'success' as const }, - paused: { label: 'Paused', color: 'warning' as const }, - error: { label: 'Error', color: 'error' as const }, + active: { label: t('feedStatus.active'), color: 'success' as const }, + paused: { label: t('feedStatus.paused'), color: 'warning' as const }, + error: { label: t('feedStatus.error'), color: 'error' as const }, }; const { label, color } = config[status as keyof typeof config] ?? { diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 7a57d72..28651ab 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -18,6 +18,7 @@ import CircleIcon from '@mui/icons-material/Circle'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import LogoutIcon from '@mui/icons-material/Logout'; +import { useTranslation } from 'react-i18next'; import { useCategories } from '../../hooks/useCategories'; import { useAuth } from '../../hooks/useAuth'; @@ -53,6 +54,7 @@ export default function Sidebar({ const { data: categories = [] } = useCategories(); const { token, logout } = useAuth(); const email = getUserEmail(token); + const { t, i18n } = useTranslation(); const handleNavigation = (path: string) => { navigate(path); @@ -63,17 +65,25 @@ export default function Sidebar({ const currentWidth = collapsed ? collapsedWidth : drawerWidth; + const toggleLanguage = () => { + const next = i18n.language === 'fr' ? 'en' : 'fr'; + i18n.changeLanguage(next); + localStorage.setItem('language', next); + }; + const navItems = [ - { label: 'Dashboard', icon: , path: '/' }, - { label: 'Feeds', icon: , path: '/feeds' }, - { label: 'Bookmarks', icon: , path: '/bookmarks' }, + { label: t('nav.dashboard'), icon: , path: '/' }, + { label: t('nav.feeds'), icon: , path: '/feeds' }, + { label: t('nav.bookmarks'), icon: , path: '/bookmarks' }, ]; const drawerContent = ( {/* Logo */} - - {!collapsed && ( + + {collapsed ? ( + + ) : ( Signalist @@ -92,6 +102,7 @@ export default function Sidebar({ minHeight: 44, justifyContent: collapsed ? 'center' : 'initial', px: collapsed ? 2 : 2.5, + borderRadius: 0, }} > - Categories + {t('nav.categories')} )} @@ -140,6 +151,7 @@ export default function Sidebar({ minHeight: 40, justifyContent: collapsed ? 'center' : 'initial', px: collapsed ? 2 : 2.5, + borderRadius: 0, }} > @@ -176,13 +188,14 @@ export default function Sidebar({ @@ -199,18 +212,61 @@ export default function Sidebar({ > {email} - + + + + EN + + | + + FR + + + + ) : ( - - - - - + <> + + + + {i18n.language.toUpperCase()} + + + + + + + + + )} )} diff --git a/frontend/src/hooks/useArticles.ts b/frontend/src/hooks/useArticles.ts index c9bd020..f67d445 100644 --- a/frontend/src/hooks/useArticles.ts +++ b/frontend/src/hooks/useArticles.ts @@ -6,7 +6,7 @@ import { markArticleUnread, type ArticleFilters, } from '../api/articles'; -import type { Article } from '../types'; +import type { Article, PaginatedArticles } from '../types'; export const ARTICLES_QUERY_KEY = ['articles']; @@ -26,10 +26,14 @@ export function useArticle(id: string) { } function updateArticleInCache(queryClient: ReturnType, updated: Article) { - queryClient.setQueriesData( + queryClient.setQueriesData( { queryKey: ARTICLES_QUERY_KEY }, - (old) => old?.map((a) => (a.id === updated.id ? updated : a)), + (old) => { + if (!old || !('items' in old)) return old; + return { ...old, items: old.items.map((a) => (a.id === updated.id ? updated : a)) }; + }, ); + queryClient.setQueryData
([...ARTICLES_QUERY_KEY, updated.id], updated); } export function useMarkArticleRead() { diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..5ae42bb --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,20 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import en from './locales/en.json'; +import fr from './locales/fr.json'; + +const savedLanguage = localStorage.getItem('language') ?? 'en'; + +i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + fr: { translation: fr }, + }, + lng: savedLanguage, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, +}); + +export default i18n; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..9d5c3d5 --- /dev/null +++ b/frontend/src/i18n/locales/en.json @@ -0,0 +1,163 @@ +{ + "common": { + "loading": "Loading...", + "error": "Error", + "retry": "Retry", + "cancel": "Cancel", + "saving": "Saving...", + "create": "Create", + "update": "Update", + "never": "Never" + }, + "nav": { + "dashboard": "Dashboard", + "feeds": "Feeds", + "bookmarks": "Bookmarks", + "categories": "Categories", + "noCategories": "No categories yet", + "logout": "Logout" + }, + "auth": { + "signIn": "Sign in", + "signUp": "Sign up", + "signInToAccount": "Sign in to your account", + "createAccount": "Create your account", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "noAccount": "Don't have an account?", + "haveAccount": "Already have an account?", + "unexpectedError": "An unexpected error occurred. Please try again.", + "passwordsMismatch": "Passwords do not match", + "resendVerification": "Resend verification email" + }, + "checkEmail": { + "title": "Check your email", + "message": "We sent a verification link to {{email}}. Click the link to activate your account.", + "messageNoEmail": "We sent a verification link to your email address. Click the link to activate your account.", + "resent": "Verification email resent.", + "resendButton": "Resend email", + "alreadyVerified": "Already verified?" + }, + "verifyEmail": { + "verifying": "Verifying your email...", + "success": "Your email has been verified!", + "invalidLink": "Invalid verification link.", + "failed": "Verification failed. Please try again.", + "resent": "Verification email resent.", + "resendButton": "Resend verification email", + "backToSignIn": "Back to sign in" + }, + "dashboard": { + "title": "Dashboard", + "unread": "{{count}} unread", + "addCategory": "Category", + "addFeed": "Add Feed", + "welcome": "Welcome to Signalist", + "welcomeMessage": "Get started by creating a category and adding your first RSS feed.", + "createFirstCategory": "Create Your First Category" + }, + "article": { + "markAsUnread": "Mark as unread", + "markAsRead": "Mark as read", + "removeBookmark": "Remove bookmark", + "addBookmark": "Add bookmark", + "openOriginal": "Open original article", + "openOriginalButton": "Open Original", + "readOnOriginal": "Read on Original Site", + "noContent": "No content available.", + "loading": "Loading article...", + "failedToLoad": "Failed to load article", + "notFound": "Article not found", + "back": "Back" + }, + "articleList": { + "loading": "Loading articles...", + "failedToLoad": "Failed to load articles", + "noArticles": "No articles found", + "willAppear": "Articles will appear here once feeds are crawled" + }, + "searchBar": { + "placeholder": "Search articles..." + }, + "bookmarks": { + "title": "Bookmarks", + "count_one": "{{count}} saved article", + "count_other": "{{count}} saved articles", + "removeConfirm": "Remove this bookmark?", + "loading": "Loading bookmarks...", + "failedToLoad": "Failed to load bookmarks", + "noBookmarks": "No bookmarks yet", + "saveForLater": "Bookmark articles to save them for later", + "openArticle": "Open article", + "removeBookmark": "Remove bookmark", + "bookmarkedOn": "Bookmarked on {{date}}" + }, + "category": { + "feeds_one": "{{count}} feed", + "feeds_other": "{{count}} feeds", + "unread": "{{count}} unread", + "addFeed": "Add Feed", + "editCategory": "Edit Category", + "deleteCategory": "Delete Category", + "deleteConfirm": "Are you sure you want to delete this category? All feeds will be removed.", + "loading": "Loading category...", + "failedToLoad": "Failed to load category", + "notFound": "Category not found" + }, + "categoryDialog": { + "addTitle": "Add Category", + "editTitle": "Edit Category", + "name": "Name", + "slug": "Slug", + "slugHelper": "URL-friendly identifier", + "description": "Description", + "color": "Color", + "cancel": "Cancel", + "saving": "Saving...", + "create": "Create", + "update": "Update" + }, + "feeds": { + "title": "Feeds", + "count_one": "{{count}} feed", + "count_other": "{{count}} feeds", + "addFeed": "Add Feed", + "noFeeds": "No feeds yet", + "addFirst": "Add your first RSS feed to get started", + "lastFetched": "Last fetched: {{date}}", + "loading": "Loading feeds...", + "failedToLoad": "Failed to load feeds", + "anErrorOccurred": "An error occurred", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Delete feed \"{{title}}\"? This will also delete all its articles." + }, + "addFeedDialog": { + "title": "Add Feed", + "urlLabel": "Feed URL", + "urlPlaceholder": "https://example.com/feed.xml", + "titleLabel": "Title (optional)", + "titleHelper": "Leave empty to auto-detect from feed", + "categoryLabel": "Category", + "cancel": "Cancel", + "adding": "Adding...", + "addFeed": "Add Feed" + }, + "editFeedDialog": { + "title": "Edit Feed", + "titleLabel": "Title", + "categoryLabel": "Category", + "statusLabel": "Status", + "active": "Active", + "paused": "Paused", + "cancel": "Cancel", + "saving": "Saving...", + "update": "Update" + }, + "feedStatus": { + "active": "Active", + "paused": "Paused", + "error": "Error" + } +} diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json new file mode 100644 index 0000000..f8bb6e7 --- /dev/null +++ b/frontend/src/i18n/locales/fr.json @@ -0,0 +1,163 @@ +{ + "common": { + "loading": "Chargement...", + "error": "Erreur", + "retry": "Réessayer", + "cancel": "Annuler", + "saving": "Enregistrement...", + "create": "Créer", + "update": "Mettre à jour", + "never": "Jamais" + }, + "nav": { + "dashboard": "Tableau de bord", + "feeds": "Flux", + "bookmarks": "Favoris", + "categories": "Catégories", + "noCategories": "Aucune catégorie", + "logout": "Déconnexion" + }, + "auth": { + "signIn": "Se connecter", + "signUp": "S'inscrire", + "signInToAccount": "Connectez-vous à votre compte", + "createAccount": "Créer votre compte", + "email": "E-mail", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "noAccount": "Pas encore de compte ?", + "haveAccount": "Déjà un compte ?", + "unexpectedError": "Une erreur inattendue s'est produite. Veuillez réessayer.", + "passwordsMismatch": "Les mots de passe ne correspondent pas", + "resendVerification": "Renvoyer l'e-mail de vérification" + }, + "checkEmail": { + "title": "Vérifiez vos e-mails", + "message": "Nous avons envoyé un lien de vérification à {{email}}. Cliquez sur le lien pour activer votre compte.", + "messageNoEmail": "Nous avons envoyé un lien de vérification à votre adresse e-mail. Cliquez sur le lien pour activer votre compte.", + "resent": "E-mail de vérification renvoyé.", + "resendButton": "Renvoyer l'e-mail", + "alreadyVerified": "Déjà vérifié ?" + }, + "verifyEmail": { + "verifying": "Vérification de votre e-mail...", + "success": "Votre e-mail a été vérifié !", + "invalidLink": "Lien de vérification invalide.", + "failed": "Échec de la vérification. Veuillez réessayer.", + "resent": "E-mail de vérification renvoyé.", + "resendButton": "Renvoyer l'e-mail de vérification", + "backToSignIn": "Retour à la connexion" + }, + "dashboard": { + "title": "Tableau de bord", + "unread": "{{count}} non lu", + "addCategory": "Catégorie", + "addFeed": "Ajouter un flux", + "welcome": "Bienvenue sur Signalist", + "welcomeMessage": "Commencez par créer une catégorie et ajouter votre premier flux RSS.", + "createFirstCategory": "Créer votre première catégorie" + }, + "article": { + "markAsUnread": "Marquer comme non lu", + "markAsRead": "Marquer comme lu", + "removeBookmark": "Retirer le favori", + "addBookmark": "Ajouter aux favoris", + "openOriginal": "Ouvrir l'article original", + "openOriginalButton": "Ouvrir l'original", + "readOnOriginal": "Lire sur le site original", + "noContent": "Aucun contenu disponible.", + "loading": "Chargement de l'article...", + "failedToLoad": "Impossible de charger l'article", + "notFound": "Article introuvable", + "back": "Retour" + }, + "articleList": { + "loading": "Chargement des articles...", + "failedToLoad": "Impossible de charger les articles", + "noArticles": "Aucun article trouvé", + "willAppear": "Les articles apparaîtront ici une fois les flux récupérés" + }, + "searchBar": { + "placeholder": "Rechercher des articles..." + }, + "bookmarks": { + "title": "Favoris", + "count_one": "{{count}} article sauvegardé", + "count_other": "{{count}} articles sauvegardés", + "removeConfirm": "Retirer ce favori ?", + "loading": "Chargement des favoris...", + "failedToLoad": "Impossible de charger les favoris", + "noBookmarks": "Aucun favori pour l'instant", + "saveForLater": "Ajoutez des articles en favoris pour les retrouver plus tard", + "openArticle": "Ouvrir l'article", + "removeBookmark": "Retirer le favori", + "bookmarkedOn": "Ajouté le {{date}}" + }, + "category": { + "feeds_one": "{{count}} flux", + "feeds_other": "{{count}} flux", + "unread": "{{count}} non lu", + "addFeed": "Ajouter un flux", + "editCategory": "Modifier la catégorie", + "deleteCategory": "Supprimer la catégorie", + "deleteConfirm": "Voulez-vous vraiment supprimer cette catégorie ? Tous les flux seront supprimés.", + "loading": "Chargement de la catégorie...", + "failedToLoad": "Impossible de charger la catégorie", + "notFound": "Catégorie introuvable" + }, + "categoryDialog": { + "addTitle": "Ajouter une catégorie", + "editTitle": "Modifier la catégorie", + "name": "Nom", + "slug": "Identifiant", + "slugHelper": "Identifiant compatible avec les URLs", + "description": "Description", + "color": "Couleur", + "cancel": "Annuler", + "saving": "Enregistrement...", + "create": "Créer", + "update": "Mettre à jour" + }, + "feeds": { + "title": "Flux", + "count_one": "{{count}} flux", + "count_other": "{{count}} flux", + "addFeed": "Ajouter un flux", + "noFeeds": "Aucun flux pour l'instant", + "addFirst": "Ajoutez votre premier flux RSS pour commencer", + "lastFetched": "Dernière récupération : {{date}}", + "loading": "Chargement des flux...", + "failedToLoad": "Impossible de charger les flux", + "anErrorOccurred": "Une erreur s'est produite", + "edit": "Modifier", + "delete": "Supprimer", + "deleteConfirm": "Supprimer le flux « {{title}} » ? Tous ses articles seront également supprimés." + }, + "addFeedDialog": { + "title": "Ajouter un flux", + "urlLabel": "URL du flux", + "urlPlaceholder": "https://exemple.com/flux.xml", + "titleLabel": "Titre (optionnel)", + "titleHelper": "Laisser vide pour détecter automatiquement depuis le flux", + "categoryLabel": "Catégorie", + "cancel": "Annuler", + "adding": "Ajout en cours...", + "addFeed": "Ajouter le flux" + }, + "editFeedDialog": { + "title": "Modifier le flux", + "titleLabel": "Titre", + "categoryLabel": "Catégorie", + "statusLabel": "Statut", + "active": "Actif", + "paused": "En pause", + "cancel": "Annuler", + "saving": "Enregistrement...", + "update": "Mettre à jour" + }, + "feedStatus": { + "active": "Actif", + "paused": "En pause", + "error": "Erreur" + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 20006fd..944969e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import theme from './theme'; import { AuthProvider } from './contexts/AuthContext'; import App from './App'; import './index.css'; +import './i18n'; const queryClient = new QueryClient({ defaultOptions: { diff --git a/frontend/src/pages/ArticlePage.tsx b/frontend/src/pages/ArticlePage.tsx index 2fc1b76..c6937d1 100644 --- a/frontend/src/pages/ArticlePage.tsx +++ b/frontend/src/pages/ArticlePage.tsx @@ -13,6 +13,7 @@ import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; import BookmarkIcon from '@mui/icons-material/Bookmark'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import { useTranslation } from 'react-i18next'; import LoadingSpinner from '../components/Common/LoadingSpinner'; import ErrorAlert from '../components/Common/ErrorAlert'; import { useArticle, useMarkArticleRead, useMarkArticleUnread } from '../hooks/useArticles'; @@ -21,6 +22,7 @@ import { useBookmarks, useCreateBookmark, useDeleteBookmark } from '../hooks/use export default function ArticlePage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { t, i18n } = useTranslation(); const { data: article, @@ -66,23 +68,26 @@ export default function ArticlePage() { const formatDate = (dateString: string | null) => { if (!dateString) return ''; - return new Date(dateString).toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - year: 'numeric', - }); + return new Date(dateString).toLocaleDateString( + i18n.language === 'fr' ? 'fr-FR' : 'en-US', + { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + } + ); }; if (isLoading) { - return ; + return ; } if (isError || !article) { return ( ); @@ -96,7 +101,7 @@ export default function ArticlePage() { onClick={() => navigate(-1)} sx={{ mb: 2 }} > - Back + {t('article.back')} {/* Banner image */} @@ -138,12 +143,12 @@ export default function ArticlePage() { {/* Action bar */} - + {article.isRead ? : } - + {isBookmarked ? : } @@ -157,7 +162,7 @@ export default function ArticlePage() { target="_blank" rel="noopener noreferrer" > - Open Original + {t('article.openOriginalButton')} @@ -182,7 +187,7 @@ export default function ArticlePage() { ) : ( - No content available. + {t('article.noContent')} )} diff --git a/frontend/src/pages/BookmarksPage.tsx b/frontend/src/pages/BookmarksPage.tsx index 88f72da..177da26 100644 --- a/frontend/src/pages/BookmarksPage.tsx +++ b/frontend/src/pages/BookmarksPage.tsx @@ -1,9 +1,11 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; import BookmarkList from '../components/Bookmark/BookmarkList'; import { useBookmarks, useDeleteBookmark } from '../hooks/useBookmarks'; export default function BookmarksPage() { + const { t } = useTranslation(); const { data: bookmarks, isLoading, @@ -15,7 +17,7 @@ export default function BookmarksPage() { const deleteBookmark = useDeleteBookmark(); const handleDelete = (id: string) => { - if (window.confirm('Remove this bookmark?')) { + if (window.confirm(t('bookmarks.removeConfirm'))) { deleteBookmark.mutate(id); } }; @@ -24,11 +26,10 @@ export default function BookmarksPage() { - Bookmarks + {t('bookmarks.title')} - {bookmarks?.length ?? 0} saved article - {bookmarks?.length !== 1 ? 's' : ''} + {t('bookmarks.count', { count: bookmarks?.length ?? 0 })} diff --git a/frontend/src/pages/CategoryPage.tsx b/frontend/src/pages/CategoryPage.tsx index 82cd781..24dc8b4 100644 --- a/frontend/src/pages/CategoryPage.tsx +++ b/frontend/src/pages/CategoryPage.tsx @@ -14,6 +14,7 @@ import RssFeedIcon from '@mui/icons-material/RssFeed'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; +import { useTranslation } from 'react-i18next'; import ArticleList from '../components/Article/ArticleList'; import SearchBar from '../components/Article/SearchBar'; import AddFeedDialog from '../components/Feed/AddFeedDialog'; @@ -34,12 +35,14 @@ import type { CreateCategoryInput, AddFeedInput } from '../types'; export default function CategoryPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { t } = useTranslation(); const [addFeedOpen, setAddFeedOpen] = useState(false); const [editCategoryOpen, setEditCategoryOpen] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); const [search, setSearch] = useState(''); const [unreadOnly, setUnreadOnly] = useState(false); + const [page, setPage] = useState(1); const { data: category, @@ -57,7 +60,7 @@ export default function CategoryPage() { isError: articlesError, error: articlesErrorData, refetch: refetchArticles, - } = useArticles({ categoryId: id, ...(search ? { search } : {}), ...(unreadOnly ? { isRead: false } : {}) }); + } = useArticles({ categoryId: id, ...(search ? { search } : {}), ...(unreadOnly ? { isRead: false } : {}), page }); const { data: feeds = [] } = useFeeds({ categoryId: id }); const { data: bookmarks } = useBookmarks(); @@ -91,11 +94,7 @@ export default function CategoryPage() { const handleDeleteCategory = () => { if (!id) return; - if ( - window.confirm( - 'Are you sure you want to delete this category? All feeds will be removed.' - ) - ) { + if (window.confirm(t('category.deleteConfirm'))) { deleteCategory.mutate(id, { onSuccess: () => navigate('/'), }); @@ -123,20 +122,20 @@ export default function CategoryPage() { }; if (categoryLoading) { - return ; + return ; } if (categoryError || !category) { return ( ); } - const unreadCount = articles?.filter((a) => !a.isRead).length ?? 0; + const unreadCount = articles?.items.filter((a) => !a.isRead).length ?? 0; return ( @@ -169,19 +168,19 @@ export default function CategoryPage() { )} navigate(`/feeds#${id}`)} /> 0 ? 'primary' : 'default'} variant={unreadOnly ? 'filled' : 'outlined'} clickable - onClick={() => setUnreadOnly((prev) => !prev)} + onClick={() => { setUnreadOnly((prev) => !prev); setPage(1); }} /> @@ -191,7 +190,7 @@ export default function CategoryPage() { startIcon={} onClick={() => setAddFeedOpen(true)} > - Add Feed + {t('category.addFeed')} setMenuAnchor(e.currentTarget)}> @@ -210,25 +209,25 @@ export default function CategoryPage() { - Edit Category + {t('category.editCategory')} - Delete Category + {t('category.deleteCategory')} - + { setSearch(v); setPage(1); }} /> { if (!email) return; @@ -43,17 +45,17 @@ export default function CheckEmailPage() { Signalist - Check your email + {t('checkEmail.title')} - We sent a verification link to{' '} - {email ? {email} : 'your email address'}. Click the - link to activate your account. + {email + ? t('checkEmail.message', { email }) + : t('checkEmail.messageNoEmail')} {resent && ( - Verification email resent. + {t('checkEmail.resent')} )} @@ -64,14 +66,14 @@ export default function CheckEmailPage() { disabled={resending || resent} sx={{ mb: 3 }} > - {resending ? : 'Resend email'} + {resending ? : t('checkEmail.resendButton')} )} - Already verified?{' '} + {t('checkEmail.alreadyVerified')}{' '} - Sign in + {t('auth.signIn')} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3838fae..af02cec 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import Chip from '@mui/material/Chip'; import Grid from '@mui/material/Grid'; import AddIcon from '@mui/icons-material/Add'; import RssFeedIcon from '@mui/icons-material/RssFeed'; +import { useTranslation } from 'react-i18next'; import ArticleList from '../components/Article/ArticleList'; import SearchBar from '../components/Article/SearchBar'; import AddFeedDialog from '../components/Feed/AddFeedDialog'; @@ -17,10 +18,12 @@ import { useBookmarks, useCreateBookmark, useDeleteBookmark } from '../hooks/use import type { CreateCategoryInput, AddFeedInput } from '../types'; export default function Dashboard() { + const { t } = useTranslation(); const [addFeedOpen, setAddFeedOpen] = useState(false); const [addCategoryOpen, setAddCategoryOpen] = useState(false); const [search, setSearch] = useState(''); const [unreadOnly, setUnreadOnly] = useState(false); + const [page, setPage] = useState(1); const { data: articles, @@ -28,7 +31,7 @@ export default function Dashboard() { isError: articlesError, error: articlesErrorData, refetch: refetchArticles, - } = useArticles({ ...(search ? { search } : {}), ...(unreadOnly ? { isRead: false } : {}) }); + } = useArticles({ ...(search ? { search } : {}), ...(unreadOnly ? { isRead: false } : {}), page }); const { data: categories = [] } = useCategories(); const { data: feeds = [] } = useFeeds(); @@ -72,7 +75,7 @@ export default function Dashboard() { } }; - const unreadCount = articles?.filter((a) => !a.isRead).length ?? 0; + const unreadCount = articles?.items.filter((a) => !a.isRead).length ?? 0; return ( @@ -86,16 +89,16 @@ export default function Dashboard() { > - Dashboard + {t('dashboard.title')} 0 ? 'primary' : 'default'} variant={unreadOnly ? 'filled' : 'outlined'} clickable - onClick={() => setUnreadOnly((prev) => !prev)} + onClick={() => { setUnreadOnly((prev) => !prev); setPage(1); }} /> @@ -105,7 +108,7 @@ export default function Dashboard() { startIcon={} onClick={() => setAddCategoryOpen(true)} > - Category + {t('dashboard.addCategory')} @@ -121,28 +124,28 @@ export default function Dashboard() { {categories.length === 0 && feeds.length === 0 ? ( - Welcome to Signalist + {t('dashboard.welcome')} - Get started by creating a category and adding your first RSS feed. + {t('dashboard.welcomeMessage')} ) : ( <> - + { setSearch(v); setPage(1); }} /> diff --git a/frontend/src/pages/FeedManagementPage.tsx b/frontend/src/pages/FeedManagementPage.tsx index 6150221..42f5c05 100644 --- a/frontend/src/pages/FeedManagementPage.tsx +++ b/frontend/src/pages/FeedManagementPage.tsx @@ -14,6 +14,7 @@ import RssFeedIcon from '@mui/icons-material/RssFeed'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; +import { useTranslation } from 'react-i18next'; import AddFeedDialog from '../components/Feed/AddFeedDialog'; import EditFeedDialog from '../components/Feed/EditFeedDialog'; import FeedStatusChip from '../components/Feed/FeedStatusChip'; @@ -25,6 +26,7 @@ import { useCategories } from '../hooks/useCategories'; import type { Feed, AddFeedInput, UpdateFeedInput } from '../types'; export default function FeedManagementPage() { + const { t, i18n } = useTranslation(); const [addDialogOpen, setAddDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); const [editingFeed, setEditingFeed] = useState(null); @@ -78,30 +80,33 @@ export default function FeedManagementPage() { }; const handleDeleteFeed = (feed: Feed) => { - if (window.confirm(`Delete feed "${feed.title}"? This will also delete all its articles.`)) { + if (window.confirm(t('feeds.deleteConfirm', { title: feed.title }))) { deleteFeed.mutate(feed.id); } }; const formatDate = (dateString: string | null) => { - if (!dateString) return 'Never'; - return new Date(dateString).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); + if (!dateString) return t('common.never'); + return new Date(dateString).toLocaleDateString( + i18n.language === 'fr' ? 'fr-FR' : 'en-US', + { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + } + ); }; if (isLoading) { - return ; + return ; } if (isError) { return ( ); @@ -119,10 +124,10 @@ export default function FeedManagementPage() { > - Feeds + {t('feeds.title')} - {feeds?.length ?? 0} feed{feeds?.length !== 1 ? 's' : ''} + {t('feeds.count', { count: feeds?.length ?? 0 })} {!feeds || feeds.length === 0 ? ( } - title="No feeds yet" - description="Add your first RSS feed to get started" + title={t('feeds.noFeeds')} + description={t('feeds.addFirst')} /> ) : ( Object.entries(feedsByCategory) @@ -178,7 +183,7 @@ export default function FeedManagementPage() { {feed.url} - Last fetched: {formatDate(feed.lastFetchedAt)} + {t('feeds.lastFetched', { date: formatDate(feed.lastFetchedAt) })} {feed.lastError && ( @@ -189,12 +194,12 @@ export default function FeedManagementPage() { } /> - + handleEditFeed(feed)}> - + handleDeleteFeed(feed)} color="error"> diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index ca1f182..f51f712 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -9,12 +9,14 @@ import Typography from '@mui/material/Typography'; import Alert from '@mui/material/Alert'; import CircularProgress from '@mui/material/CircularProgress'; import Link from '@mui/material/Link'; +import { useTranslation } from 'react-i18next'; import { useAuth } from '../hooks/useAuth'; import { isProblemError } from '../api/client'; export default function LoginPage() { const { login } = useAuth(); const navigate = useNavigate(); + const { t } = useTranslation(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); @@ -37,7 +39,7 @@ export default function LoginPage() { } setError(err.problem.detail); } else { - setError('An unexpected error occurred. Please try again.'); + setError(t('auth.unexpectedError')); } } finally { setLoading(false); @@ -65,7 +67,7 @@ export default function LoginPage() { textAlign="center" mb={4} > - Sign in to your account + {t('auth.signInToAccount')} {error && ( @@ -81,14 +83,14 @@ export default function LoginPage() { to="/check-email" state={{ email }} > - Resend verification email + {t('auth.resendVerification')} )} setEmail(e.target.value)} @@ -99,7 +101,7 @@ export default function LoginPage() { sx={{ mb: 2 }} /> setPassword(e.target.value)} @@ -115,7 +117,7 @@ export default function LoginPage() { size="large" disabled={loading || !email || !password} > - {loading ? : 'Sign in'} + {loading ? : t('auth.signIn')} @@ -125,9 +127,9 @@ export default function LoginPage() { textAlign="center" mt={3} > - Don't have an account?{' '} + {t('auth.noAccount')}{' '} - Sign up + {t('auth.signUp')} diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 526ecdf..e2876d1 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -9,11 +9,13 @@ import Typography from '@mui/material/Typography'; import Alert from '@mui/material/Alert'; import CircularProgress from '@mui/material/CircularProgress'; import Link from '@mui/material/Link'; +import { useTranslation } from 'react-i18next'; import { register } from '../api/auth'; import { isProblemError } from '../api/client'; export default function RegisterPage() { const navigate = useNavigate(); + const { t } = useTranslation(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -27,7 +29,7 @@ export default function RegisterPage() { setError(null); if (!passwordsMatch) { - setError('Passwords do not match.'); + setError(t('auth.passwordsMismatch')); return; } @@ -40,7 +42,7 @@ export default function RegisterPage() { if (isProblemError(err)) { setError(err.problem.detail); } else { - setError('An unexpected error occurred. Please try again.'); + setError(t('auth.unexpectedError')); } } finally { setLoading(false); @@ -68,7 +70,7 @@ export default function RegisterPage() { textAlign="center" mb={4} > - Create your account + {t('auth.createAccount')} {error && ( @@ -79,7 +81,7 @@ export default function RegisterPage() { setEmail(e.target.value)} @@ -90,7 +92,7 @@ export default function RegisterPage() { sx={{ mb: 2 }} /> setPassword(e.target.value)} @@ -100,7 +102,7 @@ export default function RegisterPage() { sx={{ mb: 2 }} /> setConfirmPassword(e.target.value)} @@ -110,7 +112,7 @@ export default function RegisterPage() { error={confirmPassword.length > 0 && !passwordsMatch} helperText={ confirmPassword.length > 0 && !passwordsMatch - ? 'Passwords do not match' + ? t('auth.passwordsMismatch') : undefined } sx={{ mb: 3 }} @@ -124,7 +126,7 @@ export default function RegisterPage() { loading || !email || !password || !confirmPassword } > - {loading ? : 'Sign up'} + {loading ? : t('auth.signUp')} @@ -134,9 +136,9 @@ export default function RegisterPage() { textAlign="center" mt={3} > - Already have an account?{' '} + {t('auth.haveAccount')}{' '} - Sign in + {t('auth.signIn')} diff --git a/frontend/src/pages/VerifyEmailPage.tsx b/frontend/src/pages/VerifyEmailPage.tsx index b175699..9cae2cc 100644 --- a/frontend/src/pages/VerifyEmailPage.tsx +++ b/frontend/src/pages/VerifyEmailPage.tsx @@ -8,6 +8,7 @@ import Alert from '@mui/material/Alert'; import CircularProgress from '@mui/material/CircularProgress'; import Button from '@mui/material/Button'; import Link from '@mui/material/Link'; +import { useTranslation } from 'react-i18next'; import { verifyEmail, resendVerification } from '../api/auth'; import { isProblemError } from '../api/client'; @@ -19,6 +20,7 @@ export default function VerifyEmailPage() { const [errorMessage, setErrorMessage] = useState(''); const [resending, setResending] = useState(false); const [resent, setResent] = useState(false); + const { t } = useTranslation(); const email = searchParams.get('email') ?? ''; @@ -29,14 +31,14 @@ export default function VerifyEmailPage() { if (!userId || !email || !expiresAtParam || !signature) { setStatus('error'); - setErrorMessage('Invalid verification link.'); + setErrorMessage(t('verifyEmail.invalidLink')); return; } const expiresAt = parseInt(expiresAtParam, 10); if (isNaN(expiresAt)) { setStatus('error'); - setErrorMessage('Invalid verification link.'); + setErrorMessage(t('verifyEmail.invalidLink')); return; } @@ -47,10 +49,10 @@ export default function VerifyEmailPage() { if (isProblemError(err)) { setErrorMessage(err.problem.detail); } else { - setErrorMessage('Verification failed. Please try again.'); + setErrorMessage(t('verifyEmail.failed')); } }); - }, [searchParams, email]); + }, [searchParams, email]); // eslint-disable-line react-hooks/exhaustive-deps const handleResend = async () => { if (!email) return; @@ -82,14 +84,14 @@ export default function VerifyEmailPage() { {status === 'loading' && ( <> - Verifying your email... + {t('verifyEmail.verifying')} )} {status === 'success' && ( <> - Your email has been verified! + {t('verifyEmail.success')} )} @@ -118,18 +120,18 @@ export default function VerifyEmailPage() { {resending ? ( ) : ( - 'Resend verification email' + t('verifyEmail.resendButton') )} )} {resent && ( - Verification email resent. + {t('verifyEmail.resent')} )} - Back to sign in + {t('verifyEmail.backToSignIn')} diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 1b4c170..01bac8e 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -104,6 +104,13 @@ const theme = createTheme({ }, }, }, + MuiDrawer: { + styleOverrides: { + paper: { + borderRadius: 0, + }, + }, + }, }, }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2d93095..d771a16 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,3 +1,13 @@ +// Pagination + +export interface PaginatedArticles { + items: Article[]; + total: number; + page: number; + limit: number; + pages: number; +} + // API Response Types export interface Category { diff --git a/src/Domain/Article/Handler/ListArticlesHandler.php b/src/Domain/Article/Handler/ListArticlesHandler.php index e432f0d..ff91097 100644 --- a/src/Domain/Article/Handler/ListArticlesHandler.php +++ b/src/Domain/Article/Handler/ListArticlesHandler.php @@ -6,7 +6,9 @@ use App\Domain\Article\Port\ArticleRepositoryInterface; use App\Domain\Article\Query\ListArticlesQuery; -use App\Entity\Article; +use App\Domain\Article\Query\PaginatedArticlesResult; + +use function max; final readonly class ListArticlesHandler { @@ -15,10 +17,7 @@ public function __construct( ) { } - /** - * @return Article[] - */ - public function __invoke(ListArticlesQuery $query): array + public function __invoke(ListArticlesQuery $query): PaginatedArticlesResult { $filters = ['ownerId' => $query->ownerId]; @@ -38,6 +37,16 @@ public function __invoke(ListArticlesQuery $query): array $filters['search'] = $query->search; } - return $this->articleRepository->findAll($filters); + $total = $this->articleRepository->countAll($filters); + $items = $this->articleRepository->findAll($filters, $query->page, $query->limit); + $pages = max(1, (int) ceil($total / $query->limit)); + + return new PaginatedArticlesResult( + items: $items, + total: $total, + page: $query->page, + limit: $query->limit, + pages: $pages, + ); } } diff --git a/src/Domain/Article/Port/ArticleRepositoryInterface.php b/src/Domain/Article/Port/ArticleRepositoryInterface.php index f7c601c..15d0cb7 100644 --- a/src/Domain/Article/Port/ArticleRepositoryInterface.php +++ b/src/Domain/Article/Port/ArticleRepositoryInterface.php @@ -17,7 +17,12 @@ public function find(string $id): ?Article; * * @return Article[] */ - public function findAll(array $filters = []): array; + public function findAll(array $filters = [], int $page = 1, int $limit = 20): array; + + /** + * @param array{feedId?: string, categoryId?: string, isRead?: bool, ownerId?: string, search?: string} $filters + */ + public function countAll(array $filters = []): int; /** * @return Article[] diff --git a/src/Domain/Article/Query/ListArticlesQuery.php b/src/Domain/Article/Query/ListArticlesQuery.php index 425c790..29ccc39 100644 --- a/src/Domain/Article/Query/ListArticlesQuery.php +++ b/src/Domain/Article/Query/ListArticlesQuery.php @@ -12,6 +12,8 @@ public function __construct( public ?string $categoryId = null, public ?bool $isRead = null, public ?string $search = null, + public int $page = 1, + public int $limit = 20, ) { } } diff --git a/src/Domain/Article/Query/PaginatedArticlesResult.php b/src/Domain/Article/Query/PaginatedArticlesResult.php new file mode 100644 index 0000000..10b9574 --- /dev/null +++ b/src/Domain/Article/Query/PaginatedArticlesResult.php @@ -0,0 +1,22 @@ + + * @implements ProviderInterface */ final readonly class ArticleStateProvider implements ProviderInterface { @@ -39,10 +40,7 @@ public function __construct( ) { } - /** - * @return ArticleResource|array - */ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): ArticleResource|array + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ArticleResource|PaginatedArticlesResponse { $user = $this->security->getUser(); assert($user instanceof User); @@ -55,6 +53,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $categoryId = $request?->query->get('categoryId'); $isReadParam = $request?->query->get('isRead'); $search = $request?->query->get('search'); + $pageParam = $request?->query->get('page'); + $limitParam = $request?->query->get('limit'); $isRead = null; @@ -62,17 +62,28 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $isRead = filter_var($isReadParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); } + $page = max(1, (int) ($pageParam ?? 1)); + $limit = min(100, max(1, (int) ($limitParam ?? 20))); + $query = new ListArticlesQuery( ownerId: $ownerId, feedId: is_string($feedId) ? $feedId : null, categoryId: is_string($categoryId) ? $categoryId : null, isRead: $isRead, search: is_string($search) ? $search : null, + page: $page, + limit: $limit, ); - $articles = ($this->listArticlesHandler)($query); + $result = ($this->listArticlesHandler)($query); - return array_map(self::toResource(...), $articles); + return new PaginatedArticlesResponse( + items: array_map(self::toResource(...), $result->items), + total: $result->total, + page: $result->page, + limit: $result->limit, + pages: $result->pages, + ); } $id = $uriVariables['id'] ?? ''; diff --git a/src/Infrastructure/Persistence/Article/DoctrineArticleRepository.php b/src/Infrastructure/Persistence/Article/DoctrineArticleRepository.php index fb40613..ab4e8cc 100644 --- a/src/Infrastructure/Persistence/Article/DoctrineArticleRepository.php +++ b/src/Infrastructure/Persistence/Article/DoctrineArticleRepository.php @@ -33,18 +33,20 @@ public function find(string $id): ?Article } /** - * @param array{feedId?: string, categoryId?: string, isRead?: bool, ownerId?: string} $filters + * @param array{feedId?: string, categoryId?: string, isRead?: bool, ownerId?: string, search?: string} $filters * * @return Article[] */ - public function findAll(array $filters = []): array + public function findAll(array $filters = [], int $page = 1, int $limit = 20): array { $qb = $this->entityManager->createQueryBuilder() ->select('a') ->from(Article::class, 'a') ->join('a.feed', 'f') ->orderBy('a.publishedAt', 'DESC') - ->addOrderBy('a.createdAt', 'DESC'); + ->addOrderBy('a.createdAt', 'DESC') + ->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit); $this->applyFilters($qb, $filters); @@ -54,6 +56,21 @@ public function findAll(array $filters = []): array return $result; } + /** + * @param array{feedId?: string, categoryId?: string, isRead?: bool, ownerId?: string, search?: string} $filters + */ + public function countAll(array $filters = []): int + { + $qb = $this->entityManager->createQueryBuilder() + ->select('COUNT(a.id)') + ->from(Article::class, 'a') + ->join('a.feed', 'f'); + + $this->applyFilters($qb, $filters); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + /** * @return Article[] */ diff --git a/tests/Behat/ApiContext.php b/tests/Behat/ApiContext.php index 74c470f..a707050 100644 --- a/tests/Behat/ApiContext.php +++ b/tests/Behat/ApiContext.php @@ -263,6 +263,22 @@ public function theJsonCollectionShouldBeEmpty(): void { $data = $this->getJsonResponse(); + // Handle paginated envelope format {items, total, page, limit, pages} + if (isset($data['items'])) { + $items = $data['items']; + // items may itself be a Hydra collection + /** @var array $collection */ + $collection = is_array($items) && isset($items['member']) && is_array($items['member']) + ? $items['member'] + : (is_array($items) && !isset($items['@type']) ? $items : []); + + if ($collection !== []) { + throw new RuntimeException('Expected empty collection, got: ' . json_encode($collection)); + } + + return; + } + // Handle Hydra collection format if (isset($data['member'])) { if ($data['member'] !== []) { @@ -285,8 +301,16 @@ public function theJsonCollectionShouldHaveItems(int $count): void { $data = $this->getJsonResponse(); - // Handle Hydra collection format - $actual = isset($data['member']) && is_array($data['member']) ? count($data['member']) : count($data); + // Handle paginated envelope format {items, total, page, limit, pages} + if (isset($data['items'])) { + $items = $data['items']; + $actual = is_array($items) && isset($items['member']) && is_array($items['member']) + ? count($items['member']) + : (is_array($items) && !isset($items['@type']) ? count($items) : 0); + } else { + // Handle Hydra collection format + $actual = isset($data['member']) && is_array($data['member']) ? count($data['member']) : count($data); + } if ($actual !== $count) { throw new RuntimeException(sprintf('Expected %d items, got %d', $count, $actual)); diff --git a/tests/Unit/Domain/Article/Handler/ListArticlesHandlerTest.php b/tests/Unit/Domain/Article/Handler/ListArticlesHandlerTest.php index fa7b68d..50cf5f8 100644 --- a/tests/Unit/Domain/Article/Handler/ListArticlesHandlerTest.php +++ b/tests/Unit/Domain/Article/Handler/ListArticlesHandlerTest.php @@ -7,6 +7,7 @@ use App\Domain\Article\Handler\ListArticlesHandler; use App\Domain\Article\Port\ArticleRepositoryInterface; use App\Domain\Article\Query\ListArticlesQuery; +use App\Domain\Article\Query\PaginatedArticlesResult; use App\Entity\Article; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -36,15 +37,26 @@ public function testInvokeWithNoFiltersReturnsAllArticles(): void $this->articleRepository ->expects($this->once()) - ->method('findAll') + ->method('countAll') ->with(['ownerId' => $this->ownerId]) + ->willReturn(2); + + $this->articleRepository + ->expects($this->once()) + ->method('findAll') + ->with(['ownerId' => $this->ownerId], 1, 20) ->willReturn($articles); $query = new ListArticlesQuery(ownerId: $this->ownerId); $result = ($this->handler)($query); - $this->assertCount(2, $result); + $this->assertInstanceOf(PaginatedArticlesResult::class, $result); + $this->assertCount(2, $result->items); + $this->assertSame(2, $result->total); + $this->assertSame(1, $result->page); + $this->assertSame(20, $result->limit); + $this->assertSame(1, $result->pages); } public function testInvokeWithFeedIdFilterReturnsFilteredArticles(): void @@ -54,15 +66,21 @@ public function testInvokeWithFeedIdFilterReturnsFilteredArticles(): void $this->articleRepository ->expects($this->once()) - ->method('findAll') + ->method('countAll') ->with(['ownerId' => $this->ownerId, 'feedId' => $feedId]) + ->willReturn(1); + + $this->articleRepository + ->expects($this->once()) + ->method('findAll') + ->with(['ownerId' => $this->ownerId, 'feedId' => $feedId], 1, 20) ->willReturn($articles); $query = new ListArticlesQuery(ownerId: $this->ownerId, feedId: $feedId); $result = ($this->handler)($query); - $this->assertCount(1, $result); + $this->assertCount(1, $result->items); } public function testInvokeWithIsReadFilterReturnsFilteredArticles(): void @@ -71,15 +89,21 @@ public function testInvokeWithIsReadFilterReturnsFilteredArticles(): void $this->articleRepository ->expects($this->once()) - ->method('findAll') + ->method('countAll') ->with(['ownerId' => $this->ownerId, 'isRead' => false]) + ->willReturn(1); + + $this->articleRepository + ->expects($this->once()) + ->method('findAll') + ->with(['ownerId' => $this->ownerId, 'isRead' => false], 1, 20) ->willReturn($articles); $query = new ListArticlesQuery(ownerId: $this->ownerId, isRead: false); $result = ($this->handler)($query); - $this->assertCount(1, $result); + $this->assertCount(1, $result->items); } public function testInvokeWithMultipleFiltersAppliesAllFilters(): void @@ -89,13 +113,24 @@ public function testInvokeWithMultipleFiltersAppliesAllFilters(): void $this->articleRepository ->expects($this->once()) - ->method('findAll') + ->method('countAll') ->with([ 'ownerId' => $this->ownerId, 'feedId' => $feedId, 'categoryId' => $categoryId, 'isRead' => true, ]) + ->willReturn(0); + + $this->articleRepository + ->expects($this->once()) + ->method('findAll') + ->with([ + 'ownerId' => $this->ownerId, + 'feedId' => $feedId, + 'categoryId' => $categoryId, + 'isRead' => true, + ], 1, 20) ->willReturn([]); $query = new ListArticlesQuery( @@ -107,7 +142,7 @@ public function testInvokeWithMultipleFiltersAppliesAllFilters(): void $result = ($this->handler)($query); - $this->assertCount(0, $result); + $this->assertCount(0, $result->items); } public function testInvokeWithSearchFilterPassesSearchToRepository(): void @@ -116,14 +151,44 @@ public function testInvokeWithSearchFilterPassesSearchToRepository(): void $this->articleRepository ->expects($this->once()) - ->method('findAll') + ->method('countAll') ->with(['ownerId' => $this->ownerId, 'search' => 'css grid']) + ->willReturn(1); + + $this->articleRepository + ->expects($this->once()) + ->method('findAll') + ->with(['ownerId' => $this->ownerId, 'search' => 'css grid'], 1, 20) ->willReturn($articles); $query = new ListArticlesQuery(ownerId: $this->ownerId, search: 'css grid'); $result = ($this->handler)($query); - $this->assertCount(1, $result); + $this->assertCount(1, $result->items); + } + + public function testInvokeWithPageAndLimitPaginatesCorrectly(): void + { + $this->articleRepository + ->expects($this->once()) + ->method('countAll') + ->with(['ownerId' => $this->ownerId]) + ->willReturn(45); + + $this->articleRepository + ->expects($this->once()) + ->method('findAll') + ->with(['ownerId' => $this->ownerId], 2, 20) + ->willReturn([]); + + $query = new ListArticlesQuery(ownerId: $this->ownerId, page: 2, limit: 20); + + $result = ($this->handler)($query); + + $this->assertSame(45, $result->total); + $this->assertSame(2, $result->page); + $this->assertSame(20, $result->limit); + $this->assertSame(3, $result->pages); } } From 77cd9140f97ad0c69ef98e1641c03bf5c0138dcf Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sat, 14 Mar 2026 16:13:15 +0100 Subject: [PATCH 2/4] fix(tests): init i18n in test setup to fix translation key assertions - Import i18n setup in vitest setupFiles so t() resolves real strings - Fix all 9 failing frontend tests caused by i18n migration feat(rector): add SymfonySetList::CONFIGS for service config rules feat(frontend): show bookmark indicator on article card without hover Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/Article/ArticleCard.tsx | 3 +++ frontend/src/test/setup.ts | 1 + rector.php | 1 + 3 files changed, 5 insertions(+) diff --git a/frontend/src/components/Article/ArticleCard.tsx b/frontend/src/components/Article/ArticleCard.tsx index b18bd4c..9c1d81e 100644 --- a/frontend/src/components/Article/ArticleCard.tsx +++ b/frontend/src/components/Article/ArticleCard.tsx @@ -81,6 +81,9 @@ export default function ArticleCard({ {article.feedTitle} + {isBookmarked && ( + + )} Date: Sat, 14 Mar 2026 16:16:50 +0100 Subject: [PATCH 3/4] ci: bump actions/checkout to v5 and Node.js to 24 - actions/checkout@v4 -> @v5 (Node.js 24 runtime) - node-version 20 -> 24 in frontend job Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 20f4089..19aa130 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -108,7 +108,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Lint Dockerfile (dev) uses: hadolint/hadolint-action@v3.1.0 @@ -130,7 +130,7 @@ jobs: working-directory: frontend steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Check if frontend exists id: check_frontend @@ -146,7 +146,7 @@ jobs: if: steps.check_frontend.outputs.exists == 'true' uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: frontend/package-lock.json From f72af01ac5a68d83e65345633057799416a8089e Mon Sep 17 00:00:00 2001 From: Thomas Laure Date: Sat, 14 Mar 2026 16:18:53 +0100 Subject: [PATCH 4/4] ci: bump actions/setup-node to v5 for Node.js 24 runtime Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19aa130..9b2d534 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -144,7 +144,7 @@ jobs: - name: Setup Node.js if: steps.check_frontend.outputs.exists == 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '24' cache: 'npm'