mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(epic-3): implémentation du Dashboard, mode lecture et notifications temps réel
This commit is contained in:
1 parent
1d31de95ec
commit
dc26f22f51
20 files changed
+1237
-107
No files matched your search
+325
-35
@@ -146,29 +146,144 @@ button[type="submit"]:disabled {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard {
|
||||
min-height: 100vh;
|
||||
/* Dashboard Layout */
|
||||
.dashboard-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 0.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.nav-item .label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-item .badge {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.sidebar-section h3 {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.add-feed-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-feed-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: var(--surface);
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #a855f7 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.mark-all-read-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mark-all-read-btn:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mark-all-read-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
@@ -177,52 +292,227 @@ button[type="submit"]:disabled {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-info span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
.articles-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
.articles-grid {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Article Card */
|
||||
.article-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.article-card:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.article-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.article-excerpt {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.article-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
.action-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
flex: 1;
|
||||
.action-btn.active {
|
||||
color: #fbbf24;
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
/* Reader View Overlay */
|
||||
.reader-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.reader-container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
background: var(--background);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.reader-header {
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
.reader-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
.reader-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 3rem 4rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
.reader-content .meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.reader-content .title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.reader-content .author {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
font-size: 1.125rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.content-body p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.content-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.content-body a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.content-body h1,
|
||||
.content-body h2,
|
||||
.content-body h3 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Loading/Empty States */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reader-content {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.reader-content .title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
@@ -255,8 +545,8 @@ button[type="submit"]:disabled {
|
||||
.login-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { API_BASE, handleResponse } from './client';
|
||||
|
||||
export class ApiError extends Error {
|
||||
public status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
feed_id: string;
|
||||
guid: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
author?: string;
|
||||
image_url?: string;
|
||||
published_at?: string;
|
||||
is_read: boolean;
|
||||
is_favorite: boolean;
|
||||
read_at?: string;
|
||||
created_at: string;
|
||||
feed_title?: string;
|
||||
}
|
||||
|
||||
export interface ListArticlesOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unread?: boolean;
|
||||
favorite?: boolean;
|
||||
feed_id?: string;
|
||||
}
|
||||
|
||||
export const articlesApi = {
|
||||
async list(options: ListArticlesOptions = {}): Promise<Article[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.limit) params.append('limit', options.limit.toString());
|
||||
if (options.offset) params.append('offset', options.offset.toString());
|
||||
if (options.unread) params.append('unread', 'true');
|
||||
if (options.favorite) params.append('favorite', 'true');
|
||||
|
||||
let url = `${API_BASE}/articles`;
|
||||
if (options.favorite) {
|
||||
url = `${API_BASE}/articles/favorites`;
|
||||
} else if (options.feed_id) {
|
||||
url = `${API_BASE}/feeds/${options.feed_id}/articles`;
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<Article[]>(response);
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Article> {
|
||||
const response = await fetch(`${API_BASE}/articles/${id}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<Article>(response);
|
||||
},
|
||||
|
||||
async markRead(id: string): Promise<{ is_read: boolean }> {
|
||||
const response = await fetch(`${API_BASE}/articles/${id}/read`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async markUnread(id: string): Promise<{ is_read: boolean }> {
|
||||
const response = await fetch(`${API_BASE}/articles/${id}/read`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<{ is_favorite: boolean }> {
|
||||
const response = await fetch(`${API_BASE}/articles/${id}/favorite`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async markAllRead(feedId: string): Promise<{ message: string }> {
|
||||
const response = await fetch(`${API_BASE}/feeds/${feedId}/read-all`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async markAllReadGlobal(): Promise<{ message: string }> {
|
||||
const response = await fetch(`${API_BASE}/articles/read-all`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
};
|
||||
+14
-32
@@ -1,48 +1,33 @@
|
||||
const API_BASE = '/api/v1';
|
||||
import { API_BASE, handleResponse } from './client';
|
||||
|
||||
interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
expires_at: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface RegisterResponse {
|
||||
export interface RegisterResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new ApiError(response.status, error.error || 'Request failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
async register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||
const response = await fetch(`${API_BASE}/auth/register`, {
|
||||
@@ -78,6 +63,3 @@ export const authApi = {
|
||||
return handleResponse<User>(response);
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
export type { User, LoginResponse, RegisterResponse };
|
||||
@@ -0,0 +1,18 @@
|
||||
export const API_BASE = '/api/v1';
|
||||
|
||||
export class ApiError extends Error {
|
||||
public status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new ApiError(response.status, error.error || 'Request failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { API_BASE, handleResponse } from './client';
|
||||
|
||||
export interface Feed {
|
||||
id: string;
|
||||
user_id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
site_url?: string;
|
||||
image_url?: string;
|
||||
last_fetched_at?: string;
|
||||
fetch_error?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
unread_count?: number;
|
||||
}
|
||||
|
||||
export interface AddFeedRequest {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const feedsApi = {
|
||||
async list(): Promise<Feed[]> {
|
||||
const response = await fetch(`${API_BASE}/feeds`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<Feed[]>(response);
|
||||
},
|
||||
|
||||
async add(data: AddFeedRequest): Promise<Feed> {
|
||||
const response = await fetch(`${API_BASE}/feeds`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<Feed>(response);
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Feed> {
|
||||
const response = await fetch(`${API_BASE}/feeds/${id}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<Feed>(response);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/feeds/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
await handleResponse(response);
|
||||
},
|
||||
|
||||
async importOPML(file: File): Promise<{ imported: number; skipped: number; errors?: string[] }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch(`${API_BASE}/feeds/import/opml`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Article } from '../api/articles';
|
||||
|
||||
interface ArticleCardProps {
|
||||
article: Article;
|
||||
onClick: (article: Article) => void;
|
||||
onToggleRead: (id: string, currentStatus: boolean) => void;
|
||||
onToggleFavorite: (id: string, currentStatus: boolean) => void;
|
||||
}
|
||||
|
||||
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||
const publishedDate = article.published_at
|
||||
? new Date(article.published_at).toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`article-card ${article.is_read ? 'read' : 'unread'}`}
|
||||
onClick={() => onClick(article)}
|
||||
>
|
||||
<div className="article-header">
|
||||
<span className="feed-title">{article.feed_title}</span>
|
||||
<span className="published-date">{publishedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="article-title">{article.title}</h3>
|
||||
|
||||
{article.summary && (
|
||||
<p className="article-excerpt">
|
||||
{article.summary.replace(/<[^>]*>/g, '').substring(0, 150)}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="article-footer" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className={`action-btn ${article.is_favorite ? 'active' : ''}`}
|
||||
onClick={() => onToggleFavorite(article.id, article.is_favorite)}
|
||||
title={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
|
||||
>
|
||||
{article.is_favorite ? '★' : '☆'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => onToggleRead(article.id, article.is_read)}
|
||||
title={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
|
||||
>
|
||||
{article.is_read ? '○' : '●'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Article } from '../api/articles';
|
||||
|
||||
interface ReaderViewProps {
|
||||
article: Article;
|
||||
onClose: () => void;
|
||||
onToggleFavorite: (id: string, currentStatus: boolean) => void;
|
||||
}
|
||||
|
||||
export function ReaderView({ article, onClose, onToggleFavorite }: ReaderViewProps) {
|
||||
const publishedDate = article.published_at
|
||||
? new Date(article.published_at).toLocaleString()
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="reader-overlay" onClick={onClose}>
|
||||
<div className="reader-container" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="reader-header">
|
||||
<button className="close-btn" onClick={onClose}>✕ Fermer</button>
|
||||
<div className="reader-actions">
|
||||
<button
|
||||
className={`action-btn ${article.is_favorite ? 'active' : ''}`}
|
||||
onClick={() => onToggleFavorite(article.id, article.is_favorite)}
|
||||
>
|
||||
{article.is_favorite ? '★ Favori' : '☆ Favori'}
|
||||
</button>
|
||||
<a
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="action-btn"
|
||||
>
|
||||
🌐 Source
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article className="reader-content">
|
||||
<div className="meta">
|
||||
<span className="feed-name">{article.feed_title}</span>
|
||||
<span className="date">{publishedDate}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="title">{article.title}</h1>
|
||||
|
||||
{article.author && <p className="author">Par {article.author}</p>}
|
||||
|
||||
<div
|
||||
className="content-body"
|
||||
dangerouslySetInnerHTML={{ __html: article.content || article.summary || '' }}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { feedsApi } from '../api/feeds';
|
||||
import type { Feed } from '../api/feeds';
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectFeed: (feedId: string | null) => void;
|
||||
selectedFeedId: string | null;
|
||||
}
|
||||
|
||||
export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
|
||||
const { data: feeds, isLoading } = useQuery({
|
||||
queryKey: ['feeds'],
|
||||
queryFn: feedsApi.list,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<nav className="sidebar-nav">
|
||||
<button
|
||||
className={`nav-item ${selectedFeedId === null ? 'active' : ''}`}
|
||||
onClick={() => onSelectFeed(null)}
|
||||
>
|
||||
<span className="icon">📥</span>
|
||||
<span className="label">Tous les flux</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`nav-item ${selectedFeedId === 'favorites' ? 'active' : ''}`}
|
||||
onClick={() => onSelectFeed('favorites')}
|
||||
>
|
||||
<span className="icon">★</span>
|
||||
<span className="label">Favoris</span>
|
||||
</button>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<h3>Flux</h3>
|
||||
{isLoading ? (
|
||||
<div className="sidebar-loading">Chargement...</div>
|
||||
) : (
|
||||
<div className="feed-list">
|
||||
{feeds?.map((feed: Feed) => (
|
||||
<button
|
||||
key={feed.id}
|
||||
className={`nav-item ${selectedFeedId === feed.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectFeed(feed.id)}
|
||||
>
|
||||
<span className="icon">📄</span>
|
||||
<span className="label">{feed.title}</span>
|
||||
{feed.unread_count ? (
|
||||
<span className="badge">{feed.unread_count}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button className="add-feed-btn" onClick={() => {/* TODO: Add feed modal */ }}>
|
||||
+ Ajouter un flux
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function useWebsocket() {
|
||||
const queryClient = useQueryClient();
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/api/v1/ws`;
|
||||
|
||||
const connect = () => {
|
||||
const socket = new WebSocket(wsUrl);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('WS Message:', data);
|
||||
|
||||
if (data.type === 'new_articles') {
|
||||
// Invalidate articles and feeds query to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WS message', err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WS connection closed, retrying in 5s...');
|
||||
setTimeout(connect, 5000);
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
console.error('WS error:', err);
|
||||
socket.close();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [queryClient]);
|
||||
}
|
||||
+128
-20
@@ -1,38 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { authApi } from '../api/auth';
|
||||
import { articlesApi } from '../api/articles';
|
||||
import type { Article } from '../api/articles';
|
||||
import { Sidebar } from '../components/Sidebar';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { ReaderView } from '../components/ReaderView';
|
||||
import { useWebsocket } from '../hooks/useWebsocket';
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
// Initialize WebSocket for real-time updates
|
||||
useWebsocket();
|
||||
|
||||
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
|
||||
const [viewingArticle, setViewingArticle] = useState<Article | null>(null);
|
||||
|
||||
const { data: articles, isLoading } = useQuery({
|
||||
queryKey: ['articles', selectedFeedId],
|
||||
queryFn: () => articlesApi.list({
|
||||
unread: true,
|
||||
feed_id: (selectedFeedId && selectedFeedId !== 'favorites') ? selectedFeedId : undefined,
|
||||
favorite: selectedFeedId === 'favorites' ? true : undefined
|
||||
}),
|
||||
});
|
||||
|
||||
const toggleReadMutation = useMutation({
|
||||
mutationFn: ({ id, is_read }: { id: string, is_read: boolean }) =>
|
||||
is_read ? articlesApi.markUnread(id) : articlesApi.markRead(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFavoriteMutation = useMutation({
|
||||
mutationFn: ({ id }: { id: string }) => articlesApi.toggleFavorite(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: () => selectedFeedId && selectedFeedId !== 'favorites'
|
||||
? articlesApi.markAllRead(selectedFeedId)
|
||||
: articlesApi.markAllReadGlobal(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authApi.logout();
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<h1>FlowReader</h1>
|
||||
<div className="user-info">
|
||||
<span>{user?.email}</span>
|
||||
<button onClick={handleLogout} className="logout-btn">
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
const handleArticleClick = async (article: Article) => {
|
||||
try {
|
||||
// Fetch full content and sanitize
|
||||
const fullArticle = await articlesApi.get(article.id);
|
||||
setViewingArticle(fullArticle);
|
||||
|
||||
<main className="dashboard-main">
|
||||
<div className="empty-state">
|
||||
<h2>🎉 Bienvenue !</h2>
|
||||
<p>Votre lecteur RSS est prêt.</p>
|
||||
<p className="hint">
|
||||
Les fonctionnalités de flux seront disponibles dans l'Epic 2.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
// Mark as read automatically
|
||||
if (!article.is_read) {
|
||||
toggleReadMutation.mutate({ id: article.id, is_read: false });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load article', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<Sidebar
|
||||
onSelectFeed={setSelectedFeedId}
|
||||
selectedFeedId={selectedFeedId}
|
||||
/>
|
||||
|
||||
<div className="dashboard-content">
|
||||
<header className="dashboard-header">
|
||||
<h1>{selectedFeedId === 'favorites' ? 'Favoris' : 'Nouveautés'}</h1>
|
||||
|
||||
<div className="header-actions">
|
||||
{articles && articles.length > 0 && selectedFeedId !== 'favorites' && (
|
||||
<button
|
||||
className="mark-all-read-btn"
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
>
|
||||
{markAllReadMutation.isPending ? 'Chargement...' : 'Tout marquer comme lu'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="user-info">
|
||||
<span>{user?.email}</span>
|
||||
<button onClick={handleLogout} className="logout-btn">
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="articles-main">
|
||||
{isLoading ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
) : articles?.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h2>🎉 Tout est lu !</h2>
|
||||
<p>Revenez plus tard pour de nouveaux articles.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="articles-grid">
|
||||
{articles?.map((article: Article) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
onClick={handleArticleClick}
|
||||
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
|
||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{viewingArticle && (
|
||||
<ReaderView
|
||||
article={viewingArticle}
|
||||
onClose={() => setViewingArticle(null)}
|
||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user