diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 20f4089..9b2d534 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
@@ -144,9 +144,9 @@ 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: '20'
+ node-version: '24'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
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..9c1d81e 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({
},
}}
>
-
+
{article.feedTitle}
+ {isBookmarked && (
+
+ )}
-
+
{ e.stopPropagation(); onToggleRead(article.id, article.isRead); }}
@@ -132,7 +140,7 @@ export default function ArticleCard({
{article.isRead ? : }
-
+
{ e.stopPropagation(); onToggleBookmark(article.id, isBookmarked); }}
@@ -141,7 +149,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({