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.title} +
+
+ )} + +
+
+
+ {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 */} +
+ + + + + + + +
+ +
+

+ Swipe pour lire la suite +

+
+
+
+ + {/* 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) },