diff --git a/web/package-lock.json b/web/package-lock.json
index 9bba5db..496b21e 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -12,6 +12,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
+ "react-swipeable": "^7.0.2",
"zustand": "^5.0.11"
},
"devDependencies": {
@@ -3588,6 +3589,15 @@
"react-dom": ">=18"
}
},
+ "node_modules/react-swipeable": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
+ "integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
diff --git a/web/package.json b/web/package.json
index a02b283..e5ebd46 100644
--- a/web/package.json
+++ b/web/package.json
@@ -14,6 +14,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
+ "react-swipeable": "^7.0.2",
"zustand": "^5.0.11"
},
"devDependencies": {
diff --git a/web/src/components/MobileReaderView.tsx b/web/src/components/MobileReaderView.tsx
new file mode 100644
index 0000000..de78d85
--- /dev/null
+++ b/web/src/components/MobileReaderView.tsx
@@ -0,0 +1,125 @@
+import type { Article } from '../api/articles';
+import { useSwipeable } from 'react-swipeable';
+
+interface MobileReaderViewProps {
+ article: Article;
+ onClose: () => void;
+ onToggleFavorite: (id: string) => void;
+ onNext: () => void;
+ onPrev: () => void;
+}
+
+export function MobileReaderView({ article, onClose, onToggleFavorite, onNext, onPrev }: MobileReaderViewProps) {
+ // Determine content to show: full content > summary > default message
+ let displayContent = article.content || article.summary || '
Aucun contenu disponible pour cet article.
';
+
+ // If we have a hero image, try to remove the first image from the content to avoid duplicates
+ if (article.image_url) {
+ displayContent = displayContent.replace(/
]*>/, '');
+ }
+
+ // Swipe handlers
+ const handlers = useSwipeable({
+ onSwipedLeft: () => onNext(),
+ onSwipedRight: () => onPrev(),
+ preventScrollOnSwipe: false,
+ trackMouse: true // Allow testing with mouse in devtools
+ });
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Mobile Header / Nav Indicator Hint */}
+
+
+ {/* Hero Image Section */}
+ {article.image_url && (
+
+

+
+
+ )}
+
+
+
+
+ {article.feed_title}
+ •
+ {article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }) : 'Aujourd\'hui'}
+
+
+
+ {article.title}
+
+
+
+
+
+ {/* Mobile Action Bar */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Floating Close Button for Mobile - Easier thumb reach */}
+
+
+
+
+ );
+}
diff --git a/web/src/index.css b/web/src/index.css
index 9b1b35b..dde8418 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -2,7 +2,7 @@
@import "tailwindcss";
@theme {
- --color-carbon: #F0F0EB;
+ --color-carbon: #F9F7F1;
--color-carbon-light: #FFFFFF;
--color-carbon-dark: #E7E5E4;
--color-nature: #166534;
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
index 61641a8..1de8104 100644
--- a/web/src/pages/DashboardPage.tsx
+++ b/web/src/pages/DashboardPage.tsx
@@ -4,16 +4,33 @@ import { articlesApi } from '../api/articles';
import { feedsApi } from '../api/feeds';
import { ArticleCard } from '../components/ArticleCard';
import { ReaderView } from '../components/ReaderView';
+import { MobileReaderView } from '../components/MobileReaderView';
import { useWebsocket } from '../hooks/useWebsocket';
+import { useEffect } from 'react';
import type { Article } from '../api/articles';
interface DashboardPageProps {
selectedFeedId: string | null;
}
+// Helper Hook for Responsive Split
+function useIsMobile() {
+ const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches);
+
+ useEffect(() => {
+ const media = window.matchMedia('(max-width: 768px)');
+ const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches);
+ media.addEventListener('change', listener); // Modern browsers support this
+ return () => media.removeEventListener('change', listener);
+ }, []);
+
+ return isMobile;
+}
+
export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
const queryClient = useQueryClient();
const [selectedArticle, setSelectedArticle] = useState(null);
+ const isMobile = useIsMobile();
// Real-time sync
useWebsocket();
@@ -143,11 +160,41 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
{selectedArticle && (
- setSelectedArticle(null)}
- onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
- />
+ isMobile ? (
+ setSelectedArticle(null)}
+ onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
+ onNext={() => {
+ if (!articles) return;
+ const idx = articles.findIndex(a => a.id === selectedArticle.id);
+ if (idx < articles.length - 1) {
+ const nextArticle = articles[idx + 1];
+ setSelectedArticle(nextArticle);
+ if (!nextArticle.is_read) {
+ toggleReadMutation.mutate({ id: nextArticle.id, is_read: true });
+ }
+ } else {
+ // End of list feedback?
+ setSelectedArticle(null); // Close for now or loop? Closing feels right for "Done".
+ }
+ }}
+ onPrev={() => {
+ if (!articles) return;
+ const idx = articles.findIndex(a => a.id === selectedArticle.id);
+ if (idx > 0) {
+ const prevArticle = articles[idx - 1];
+ setSelectedArticle(prevArticle);
+ }
+ }}
+ />
+ ) : (
+ setSelectedArticle(null)}
+ onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
+ />
+ )
)}
);
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index 74bbc51..c416c9d 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -8,7 +8,7 @@ export default {
extend: {
colors: {
carbon: {
- DEFAULT: '#FAFAF9', // Stone-50 (Warm Paper Background)
+ DEFAULT: '#F9F7F1', // Warm Cream (Newspaper)
light: '#FFFFFF', // Pure White (Cards)
dark: '#E7E5E4', // Stone-200 (Borders/Inputs)
},