mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-19 05:08:31 +02:00
feat(mobile): add swipe navigation and warm newspaper styling
This commit is contained in:
1 parent
d2b755bf28
commit
dcdd2cc11f
6 files changed
+190
-7
No files matched your search
Generated
+10
@@ -12,6 +12,7 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3588,6 +3589,15 @@
|
|||||||
"react-dom": ">=18"
|
"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": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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 || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
|
||||||
|
|
||||||
|
// 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(/<img[^>]*>/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe handlers
|
||||||
|
const handlers = useSwipeable({
|
||||||
|
onSwipedLeft: () => onNext(),
|
||||||
|
onSwipedRight: () => onPrev(),
|
||||||
|
preventScrollOnSwipe: false,
|
||||||
|
trackMouse: true // Allow testing with mouse in devtools
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-md flex justify-center items-start overflow-y-auto animate-in fade-in duration-500"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...handlers}
|
||||||
|
className="w-full min-h-screen bg-carbon-light shadow-2xl relative animate-in slide-in-from-bottom duration-500 pb-20"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Mobile Header / Nav Indicator Hint */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 z-50 bg-gradient-to-r from-transparent via-white/20 to-transparent opacity-50"></div>
|
||||||
|
|
||||||
|
{/* Hero Image Section */}
|
||||||
|
{article.image_url && (
|
||||||
|
<div className="w-full h-[40vh] relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={article.image_url}
|
||||||
|
alt={article.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`px-6 ${!article.image_url ? 'pt-12' : 'pt-6'}`}>
|
||||||
|
<header className="mb-8">
|
||||||
|
<div className="flex items-center space-x-2 text-nature text-[10px] uppercase tracking-[0.3em] font-black mb-4">
|
||||||
|
<span className="bg-nature/10 px-2 py-1 rounded">{article.feed_title}</span>
|
||||||
|
<span className="opacity-30">•</span>
|
||||||
|
<span>{article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }) : 'Aujourd\'hui'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-serif text-paper-white leading-[1.2] italic-nature tracking-tight">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="magazine-content text-paper-white/90 text-lg font-reading leading-relaxed space-y-6 selection:bg-nature/20 break-words overflow-x-hidden mb-12"
|
||||||
|
dangerouslySetInnerHTML={{ __html: displayContent }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mobile Action Bar */}
|
||||||
|
<div className="flex items-center justify-between py-6 border-y border-white/5 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleFavorite(article.id)}
|
||||||
|
className={`flex items-center space-x-3 px-4 py-2 rounded-full border transition-all duration-300 ${article.is_favorite
|
||||||
|
? 'bg-nature text-white border-nature'
|
||||||
|
: 'border-paper-white/20 text-nature hover:bg-nature/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.2em] font-black">
|
||||||
|
{article.is_favorite ? 'Favori' : 'Favori'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={article.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-3 bg-white/5 rounded-full text-paper-muted hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="text-center pb-24 opacity-60">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-paper-muted animate-pulse">
|
||||||
|
Swipe pour lire la suite
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Close Button for Mobile - Easier thumb reach */}
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 pointer-events-none">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="w-12 h-12 bg-carbon/80 backdrop-blur border border-white/10 text-white rounded-full shadow-lg flex items-center justify-center active:scale-90 transition-transform pointer-events-auto"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-carbon: #F0F0EB;
|
--color-carbon: #F9F7F1;
|
||||||
--color-carbon-light: #FFFFFF;
|
--color-carbon-light: #FFFFFF;
|
||||||
--color-carbon-dark: #E7E5E4;
|
--color-carbon-dark: #E7E5E4;
|
||||||
--color-nature: #166534;
|
--color-nature: #166534;
|
||||||
|
|||||||
@@ -4,16 +4,33 @@ import { articlesApi } from '../api/articles';
|
|||||||
import { feedsApi } from '../api/feeds';
|
import { feedsApi } from '../api/feeds';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { ReaderView } from '../components/ReaderView';
|
import { ReaderView } from '../components/ReaderView';
|
||||||
|
import { MobileReaderView } from '../components/MobileReaderView';
|
||||||
import { useWebsocket } from '../hooks/useWebsocket';
|
import { useWebsocket } from '../hooks/useWebsocket';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import type { Article } from '../api/articles';
|
import type { Article } from '../api/articles';
|
||||||
|
|
||||||
interface DashboardPageProps {
|
interface DashboardPageProps {
|
||||||
selectedFeedId: string | null;
|
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) {
|
export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Real-time sync
|
// Real-time sync
|
||||||
useWebsocket();
|
useWebsocket();
|
||||||
@@ -143,11 +160,41 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedArticle && (
|
{selectedArticle && (
|
||||||
<ReaderView
|
isMobile ? (
|
||||||
article={selectedArticle}
|
<MobileReaderView
|
||||||
onClose={() => setSelectedArticle(null)}
|
article={selectedArticle}
|
||||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
onClose={() => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReaderView
|
||||||
|
article={selectedArticle}
|
||||||
|
onClose={() => setSelectedArticle(null)}
|
||||||
|
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
carbon: {
|
carbon: {
|
||||||
DEFAULT: '#FAFAF9', // Stone-50 (Warm Paper Background)
|
DEFAULT: '#F9F7F1', // Warm Cream (Newspaper)
|
||||||
light: '#FFFFFF', // Pure White (Cards)
|
light: '#FFFFFF', // Pure White (Cards)
|
||||||
dark: '#E7E5E4', // Stone-200 (Borders/Inputs)
|
dark: '#E7E5E4', // Stone-200 (Borders/Inputs)
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user