diff --git a/web/package-lock.json b/web/package-lock.json
index d4acd06..bda5aa1 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.90.20",
+ "@use-gesture/react": "^10.3.1",
+ "framer-motion": "^12.34.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
@@ -2043,6 +2045,24 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@use-gesture/core": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
+ "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
+ "license": "MIT"
+ },
+ "node_modules/@use-gesture/react": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
+ "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@use-gesture/core": "10.3.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
@@ -2752,6 +2772,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.34.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz",
+ "integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.34.0",
+ "motion-utils": "^12.29.2",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3322,6 +3369,21 @@
"node": "*"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.34.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz",
+ "integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.29.2"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.29.2",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
+ "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3794,6 +3856,12 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/web/package.json b/web/package.json
index 845eab9..2e1be73 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,8 @@
},
"dependencies": {
"@tanstack/react-query": "^5.90.20",
+ "@use-gesture/react": "^10.3.1",
+ "framer-motion": "^12.34.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx
index ab8f3e0..a83b7fb 100644
--- a/web/src/components/Sidebar.tsx
+++ b/web/src/components/Sidebar.tsx
@@ -7,9 +7,11 @@ import { AddFeedModal } from './AddFeedModal';
interface SidebarProps {
onSelectFeed: (feedId: string | null) => void;
selectedFeedId: string | null;
+ onEnterFocus: () => void;
+ isFocusMode: boolean;
}
-export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
+export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMode }: SidebarProps) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const queryClient = useQueryClient();
@@ -140,6 +142,25 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
+
+ {/* Focus Mode Entry */}
+
+
+
{feeds?.map((feed) => (
+ {/* Focus Mode CTA Widget */}
+ {!selectedFeedId && articles && articles.length > 0 && (
+
+ {/* decorative circles */}
+
+
+
+
+
+
+ Nouveauté
+
+
+
+ Votre session de lecture
+
+
+ {articles.filter(a => !a.is_read).length} articles non lus vous attendent. Passez en mode Focus pour une lecture fluide et sans distraction.
+
+
+
+
+
{ e.stopPropagation(); onEnterFocus(); }}
+ className="bg-white text-nature font-bold text-sm uppercase tracking-widest px-8 py-4 rounded-full shadow-lg hover:bg-gray-50 hover:shadow-xl hover:scale-105 transition-all flex items-center"
+ >
+ Lancer le Mode Focus
+
+
+
+
+ )}
+
{/* Grid */}
{articles && articles.length > 0 ? (
diff --git a/web/src/pages/FocusPage.tsx b/web/src/pages/FocusPage.tsx
new file mode 100644
index 0000000..a9bc55d
--- /dev/null
+++ b/web/src/pages/FocusPage.tsx
@@ -0,0 +1,90 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { articlesApi } from '../api/articles';
+import { FocusCardStack } from '../components/focus/FocusCardStack';
+import { FocusEmptyState } from '../components/focus/FocusEmptyState';
+
+interface FocusPageProps {
+ onExit: () => void;
+}
+
+export function FocusPage({ onExit }: FocusPageProps) {
+ const queryClient = useQueryClient();
+ const [isComplete, setIsComplete] = useState(false);
+
+ // Fetch ONLY unread articles
+ const { data: articles, isLoading } = useQuery({
+ queryKey: ['articles', 'focus-mode'],
+ queryFn: () => articlesApi.list({ unread: true, limit: 100 }), // Cap at 100 for performance
+ staleTime: 0, // Always fresh for focus mode
+ });
+
+ // Mutation to mark read
+ const markReadMutation = useMutation({
+ mutationFn: (id: string) => articlesApi.markRead(id),
+ onSuccess: () => {
+ // Invalidate main lists to sync up when returning
+ queryClient.invalidateQueries({ queryKey: ['articles'] });
+ queryClient.invalidateQueries({ queryKey: ['feeds'] });
+ },
+ });
+
+ const handleMarkRead = (id: string) => {
+ markReadMutation.mutate(id);
+ };
+
+ const handleKeep = (id: string) => {
+ // Do nothing api-wise, just skip.
+ // Or strictly speaking, we could have a "skipped" list, but for now just don't mark read.
+ console.log('Skipped:', id);
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ // Filter out locally read? No, we handle that in stack index.
+ // But if API returns empty:
+ if (!articles || articles.length === 0) {
+ return
;
+ }
+
+ if (isComplete) {
+ return
;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Mode Focus
+
+
+
+
+
+
+ {/* Main Deck Area */}
+
+ setIsComplete(true)}
+ />
+
+
+ );
+}