mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(web): overhaul frontend Paper Dark, migration Tailwind v4 et composants magazine
This commit is contained in:
1 parent
8777d3b970
commit
8ed1782de6
16 files changed
+1998
-238
No files matched your search
@@ -1,2 +1,3 @@
|
||||
workflow_status:
|
||||
create-architecture: c:\Users\Michael\VSCODE\FlowReader\_bmad-output\planning-artifacts\architecture.md
|
||||
create-ux-design: c:\Users\Michael\VSCODE\FlowReader\_bmad-output\planning-artifacts\ux-design-specification.md
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Paper Dark",
|
||||
"background": "#1a1a1a",
|
||||
"container": "#262626",
|
||||
"border": "#d4af37",
|
||||
"text": "#e5e5e5",
|
||||
"accent": "#d4af37",
|
||||
"muted": "#a3a3a3"
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowReader - Design Showcase (Paper Dark)</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:wght@700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--surface: #262626;
|
||||
--accent: #d4af37;
|
||||
--text-primary: #e5e5e5;
|
||||
--text-secondary: #a3a3a3;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.serif { font-family: 'Merriweather', serif; }
|
||||
.display { font-family: 'Playfair Display', serif; }
|
||||
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.accent-border { border-top: 3px solid var(--accent); }
|
||||
|
||||
.gold-glow { text-shadow: 0 0 10px rgba(212, 175, 55, 0.3); }
|
||||
|
||||
/* Variations */
|
||||
.v-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; }
|
||||
.v-list { max-width: 800px; margin: 0 auto; display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
|
||||
/* Reader View Simulation */
|
||||
#reader-view {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: var(--bg);
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-gold { background-color: var(--accent); color: #1a1a1a; }
|
||||
.btn-gold:hover { opacity: 0.9; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="p-4 md:p-8">
|
||||
|
||||
<header class="mb-12 border-b border-white/10 pb-6 flex justify-between items-end">
|
||||
<div>
|
||||
<h1 class="display text-4xl mb-2 gold-glow uppercase tracking-widest text-[#d4af37]">FlowReader</h1>
|
||||
<p class="text-secondary tracking-wide">Direction Design : <span class="text-white font-semibold">Paper Dark</span></p>
|
||||
</div>
|
||||
<nav class="flex gap-4">
|
||||
<button onclick="setView('grid')" class="text-sm opacity-50 hover:opacity-100 transition">GRILLE</button>
|
||||
<button onclick="setView('list')" class="text-sm opacity-50 hover:opacity-100 transition">LISTE</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="v-grid">
|
||||
<!-- Card 1: Variation A (Image Hero) -->
|
||||
<article class="card accent-border" onclick="openReader('L\'Art de la Lecture Minimaliste', 'L\'essentiel est invisible pour les yeux RSS...')">
|
||||
<div class="h-48 bg-gradient-to-br from-[#d4af37]/20 to-[#262626] flex items-center justify-center">
|
||||
<span class="display text-4xl opacity-20 italic">Minimalist</span>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<span class="text-[10px] uppercase tracking-widest text-[#d4af37] mb-2 block">Culture & Design</span>
|
||||
<h2 class="display text-xl mb-3 leading-tight">L'Art de la Lecture Minimaliste</h2>
|
||||
<p class="text-secondary text-sm leading-relaxed line-clamp-3">Comment réduire le bruit numérique pour se concentrer sur l'essentiel. Une exploration des interfaces sacrées...</p>
|
||||
<div class="mt-4 flex justify-between items-center text-[10px] text-zinc-500 uppercase tracking-tighter">
|
||||
<span>5 MIN READ</span>
|
||||
<span>OCT 24, 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card 2: Variation B (Typo Focused) -->
|
||||
<article class="card p-6 border border-white/5" onclick="openReader('La Renaissance des Flux RSS', 'Pourquoi nous revenons tous vers le web ouvert...')">
|
||||
<span class="text-[10px] uppercase tracking-widest text-[#d4af37] mb-2 block">Technology</span>
|
||||
<h2 class="display text-2xl mb-4 leading-tight italic">"La Renaissance des Flux RSS : Un retour aux sources du web."</h2>
|
||||
<p class="serif text-secondary text-base leading-relaxed mb-6">L'algorithme nous a trahi. Le flux RSS est le dernier rempart de la liberté éditoriale numérique.</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-zinc-700"></div>
|
||||
<div class="text-[10px] text-zinc-400">
|
||||
<p class="font-bold text-white uppercase">Jean-Michel OpenWeb</p>
|
||||
<p>REDACTEUR EN CHEF</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card 3: Variation C (Visual Impact) -->
|
||||
<article class="card relative group h-80 bg-zinc-800" onclick="openReader('Voyage Spatial : Mars 2026', 'Les premières images de la colonie alpha...')">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black to-transparent opacity-80"></div>
|
||||
<div class="absolute inset-0 p-6 flex flex-col justify-end">
|
||||
<span class="text-[10px] uppercase tracking-widest text-white/50 mb-2 block">Exploration</span>
|
||||
<h2 class="display text-3xl mb-2 leading-tight group-hover:text-[#d4af37] transition-colors">Mars 2026</h2>
|
||||
<p class="text-white/70 text-sm line-clamp-2">Les premières images haute définition de la base Utopia Planitia viennent d'arriver.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card 4: Variation D (Magazine Grid) -->
|
||||
<article class="card accent-border" onclick="openReader('Gastronomie : Le Café Parfait', 'Guide ultime pour une extraction sacrée...')">
|
||||
<div class="h-40 bg-zinc-900 flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-[#d4af37]/30" fill="currentColor" viewBox="0 0 24 24"><path d="M2 21h18v-2H2M20 8h-2V5h2m0-2H4v10a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4v-3h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z"/></svg>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h2 class="display text-xl mb-3">L'Alchimie du Grain</h2>
|
||||
<p class="text-secondary text-sm">Le secret réside dans la température de l'eau et la patience de l'esprit.</p>
|
||||
<div class="mt-4 border-t border-white/5 pt-4">
|
||||
<span class="text-[#d4af37] text-xs font-semibold">↑ FAVORITE</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<!-- Simulation du Reader Mode -->
|
||||
<div id="reader-view">
|
||||
<button onclick="closeReader()" class="mb-8 text-secondary hover:text-white uppercase tracking-widest text-xs flex items-center gap-2">
|
||||
← RETOUR AU MAGAZINE
|
||||
</button>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<header class="text-center mb-16">
|
||||
<span id="read-category" class="text-[10px] uppercase tracking-[0.3em] text-[#d4af37] mb-4 block">CULTURE</span>
|
||||
<h1 id="read-title" class="display text-5xl md:text-6xl mb-8 leading-tight italic">Titre de l'Article</h1>
|
||||
<div class="h-px w-24 bg-[#d4af37]/30 mx-auto mb-8"></div>
|
||||
<div class="text-secondary text-sm tracking-widest uppercase">
|
||||
Par Michael • 4 Février 2026
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<figure class="mb-12 aspect-video bg-zinc-800 rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<div class="text-zinc-600 display text-2xl italic">Image Principale Centrée</div>
|
||||
</figure>
|
||||
|
||||
<div class="serif text-lg md:text-xl leading-relaxed text-zinc-300 space-y-6">
|
||||
<p>Ceci est une simulation de l'expérience de lecture "Livre". L'utilisation d'une police Serif comme <span class="text-white italic">Merriweather</span> permet de réduire la fatigue visuelle lors d'une lecture prolongée.</p>
|
||||
<p>L'espacement vertical (leading) est généreux pour permettre aux mots de respirer. Aucun bouton, aucune publicité, aucun menu distractif. Juste le texte et vous.</p>
|
||||
<p id="read-content">Contenu extrait et nettoyé...</p>
|
||||
<blockquote class="border-l-2 border-[#d4af37] pl-6 py-2 my-12 italic text-2xl text-white display">
|
||||
"La simplicité est l'ultime sophistication."
|
||||
</blockquote>
|
||||
<p>Imaginez faire défiler ce texte avec une fluidité absolue, où chaque image apparaît comme un point d'ancrage visuel dans votre voyage informatif.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="mt-20 pt-12 border-t border-white/5 text-center">
|
||||
<div class="inline-flex gap-8 p-4 bg-zinc-900/50 rounded-full border border-white/5 px-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full bg-[#1a1a1a]"></div>
|
||||
<span class="text-xs text-zinc-500 uppercase tracking-widest">Background</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full bg-[#d4af37]"></div>
|
||||
<span class="text-xs text-zinc-500 uppercase tracking-widest">Accent Gold</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full bg-[#e5e5e5]"></div>
|
||||
<span class="text-xs text-zinc-500 uppercase tracking-widest">Text</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-zinc-600 text-[10px] mt-8 tracking-widest uppercase">FlowReader UX Prototype v0.1 • Crafted with Tailwind</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function setView(type) {
|
||||
const main = document.getElementById('main-content');
|
||||
if (type === 'list') {
|
||||
main.classList.remove('v-grid');
|
||||
main.classList.add('v-list');
|
||||
} else {
|
||||
main.classList.remove('v-list');
|
||||
main.classList.add('v-grid');
|
||||
}
|
||||
}
|
||||
|
||||
function openReader(title, content) {
|
||||
document.getElementById('read-title').innerText = title;
|
||||
document.getElementById('read-content').innerText = content;
|
||||
document.getElementById('reader-view').style.display = 'block';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeReader() {
|
||||
document.getElementById('reader-view').style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,372 @@
|
||||
---
|
||||
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
|
||||
inputDocuments:
|
||||
- _bmad-output/planning-artifacts/prd.md
|
||||
- _bmad-output/project-context.md
|
||||
---
|
||||
|
||||
# UX Design Specification FlowReader
|
||||
|
||||
**Author:** Michael
|
||||
**Date:** 2026-02-04
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
### Project Vision
|
||||
|
||||
FlowReader est conçu pour être un lecteur de flux RSS/Atom prônant la simplicité radicale et une expérience de lecture immersive ("Lecture Plaisir"). L'objectif est d'éliminer toute friction visuelle et technique pour se concentrer sur le contenu, tout en restant extrêmement performant (< 150Mo RAM).
|
||||
|
||||
### Target Users
|
||||
|
||||
- **Michael (Utilisateur Principal)** : Lecteur quotidien cherchant à rattraper son retard rapidement via un dashboard unifié sur mobile.
|
||||
- **Paul (Nouvel Arrivant)** : Utilisateur cherchant une solution auto-hébergée simple à configurer via import OPML.
|
||||
|
||||
### Key Design Challenges
|
||||
|
||||
- **Fluidité Tactile** : Implémenter des interactions (swipe, transitions) fluides sans alourdir l'empreinte mémoire.
|
||||
- **Uniformité Visuelle** : Transformer des flux RSS hétérogènes en un format de lecture "Livre" cohérent et épuré.
|
||||
- **Efficience Mobile** : Optimiser l'interface pour une utilisation à une seule main avec des actions rapides.
|
||||
|
||||
### Design Opportunities
|
||||
|
||||
- **Typographie Premium** : Utiliser des polices et des espacements optimisés pour la lecture longue durée.
|
||||
- **Feedback Gratifiant** : Intégrer des micro-animations subtiles pour les actions de marquage lu, renforçant le sentiment de progression.
|
||||
|
||||
## Core User Experience
|
||||
|
||||
### Defining Experience Detail
|
||||
|
||||
L'expérience cœur repose sur une vitesse de navigation absolue et une immersion totale. L'utilisateur doit pouvoir parcourir ses flux et entrer dans la lecture profonde sans aucune latence perçue ni distraction visuelle.
|
||||
|
||||
### Platform Strategy
|
||||
|
||||
- **Mobile-First** : Priorité au tactile, navigation par gestes (swipe) et ergonomie à une seule main.
|
||||
- **Desktop Premium** : Layout en grille de cartes avec images centrées pour un aspect "Magazine" élégant, se distinguant des lecteurs RSS traditionnels.
|
||||
|
||||
### Effortless Interactions
|
||||
|
||||
- **Lecture Nettoyée** : Extraction et formatage automatique ("Livre") du contenu dès l'ouverture.
|
||||
- **Marquage Lu Predictif** : Transitions fluides montrant l'état de l'article sans interrompre le flux de lecture.
|
||||
|
||||
### Critical Success Moments
|
||||
|
||||
- **Le Dashboard "Zen"** : Atterrir sur une interface propre qui donne envie de lire.
|
||||
- **La Satisfaction du "Tout Lu"** : Un état final gratifiant visuellement.
|
||||
- **Rendu Instantané** : Ouverture immédiate de l'article quel que soit le device.
|
||||
|
||||
### Experience Principles
|
||||
|
||||
- **Clarté Monacale** : Zéro distraction, focus sur le texte.
|
||||
- **Vitesse Perçue** : Chaque interaction reçoit un feedback immédiat.
|
||||
- **Impact Visuel** : Briser la monotonie par un design soigné et moderne.
|
||||
|
||||
## Desired Emotional Response
|
||||
|
||||
### Primary Emotional Goals
|
||||
|
||||
- **Calme & Focus** : Créer un sanctuaire de lecture loin du chaos des notifications et des réseaux sociaux.
|
||||
- **Satisfaction** : Procurer un sentiment d'achèvement et de clarté mentale une fois les flux parcourus.
|
||||
|
||||
### Emotional Journey Mapping
|
||||
|
||||
- **Entrée** : Ouverture de l'app -> Sentiment de calme, dashboard ordonné.
|
||||
- **Lecture** : Dans l'article -> Focus profond, immersion bibliographique ("Livre").
|
||||
- **Sortie** : "Tout lu" -> Satisfaction du travail accompli, esprit libéré.
|
||||
|
||||
### Micro-Emotions
|
||||
|
||||
- **Confiance** : L'interface réagit exactement comme prévu, sans surprise technique.
|
||||
- **Découverte** : Éveil de la curiosité grâce à une présentation visuelle élégante.
|
||||
|
||||
### Design Implications
|
||||
|
||||
- **Anti-Stress** : Pas de compteurs de non-lus en rouge anxiogène.
|
||||
- **Minimalisme** : Réduction maximale du bruit visuel (icons, bordures, menus secondaires).
|
||||
|
||||
### Emotional Design Principles
|
||||
|
||||
- **Respect de l'Attention** : L'app s'efface devant le contenu.
|
||||
- **Gratitude Gestuelle** : Les gestes tactiles doivent être doux et gratifiants visuellement.
|
||||
|
||||
## UX Pattern Analysis & Inspiration
|
||||
|
||||
### Inspiring Products Analysis
|
||||
|
||||
**Flipboard** est notre référence majeure pour son approche "Magazine Numérique".
|
||||
|
||||
- **Points Clefs** : Hiérarchie visuelle forte, utilisation de l'image comme point d'ancrage, interface immersive ("Full Screen").
|
||||
- **Leçon** : Briser la monotonie des listes standard par une grille de cartes riche et aérée.
|
||||
|
||||
### Transferable UX Patterns
|
||||
|
||||
- **Cards Magazine** : Utiliser des cartes de tailles variables ou une grille ordonnée avec images centrées sur Desktop.
|
||||
- **Hidden Controls** : Dissimuler les métadonnées et boutons secondaires derrière un geste ou une zone d'interaction secondaire.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Navigation Complexité** : Éviter les gestes trop spécifiques (comme le "Flip" 3D) qui pourraient nuire à la performance ou à l'accessibilité.
|
||||
- **Surcharge d'Onboarding** : Ne pas demander de choix complexes à l'utilisateur dès l'ouverture.
|
||||
|
||||
### Design Inspiration Strategy
|
||||
|
||||
Créer un **"Flip-Lite"** : L'élégance visuelle et la typographie haut de gamme d'un magazine imprimé, mais avec la réactivité et la légèreté d'une Single Page App optimisée.
|
||||
|
||||
## Design System Foundation
|
||||
|
||||
### Design System Choice
|
||||
|
||||
#### Custom Tailwind CSS v3
|
||||
|
||||
### Rationale for Selection
|
||||
|
||||
- **Performance Optimale** : Maîtrise totale du bundle CSS/JS pour respecter la contrainte de < 150Mo de RAM.
|
||||
- **Fidélité au Design** : Liberté totale pour implémenter le layout "Magazine" sans les contraintes d'une bibliothèque de composants pré-stylés.
|
||||
- **Expérience Développeur** : Tailwind est déjà intégré au projet, capitalisant sur l'existant.
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
Utilisation de Tailwind pour définir un système de tokens (couleurs, typo, spacing) dans `tailwind.config.js` et création de composants React atomiques et fonctionnels.
|
||||
|
||||
### Customization Strategy
|
||||
|
||||
Focus sur une échelle typographique "Serif" pour le contenu des articles et "Sans-serif" pour l'interface UI, avec des grilles CSS dynamiques pour le layout Desktop par cartes.
|
||||
|
||||
## Core Experience Mechanics
|
||||
|
||||
### Defining Experience Detail Context
|
||||
|
||||
Un **"Magazine Instantané"** où chaque flux s'ouvre comme une page d'édition haut de gamme. L'interaction signature est l'expansion fluide de la carte vers la vue lecteur, garantissant une continuité visuelle sans rupture.
|
||||
|
||||
### User Mental Model
|
||||
|
||||
L'utilisateur perçoit FlowReader non pas comme un outil de gestion technique, mais comme une bibliothèque personnelle de contenus choisis. Chaque article ouvert est perçu comme une extension de la carte initiale.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- **Expansion Instantanée** : La transition entre la grille et l'article doit être fluide et durer moins de 300ms.
|
||||
- **Lecture Focalisée** : Dès l'ouverture, seul le texte et les visuels cruciaux sont visibles.
|
||||
- **Légèreté Visuelle** : L'interface doit paraître "respirer" grâce à des marges généreuses et un usage minimaliste des icônes.
|
||||
|
||||
### Novel UX Patterns
|
||||
|
||||
- **Animated Card Expansion** : Utilisation de transitions `Framer Motion` ou CSS ultra-légères pour animer l'élément de la carte vers le format plein écran.
|
||||
- **Dynamic Centered Cards** : Grid Desktop Tailwind s'adaptant à l'aspect ratio des images de couverture pour conserver l'aspect magazine.
|
||||
|
||||
### Experience Mechanics Flow
|
||||
|
||||
1. **Initiation** : L'utilisateur effleure ou clique sur une carte dans la grille.
|
||||
2. **Interaction** : La carte s'agrandit, l'image centrée devient l'en-tête de l'article, le texte s'écoule en dessous.
|
||||
3. **Rétroaction** : Changement d'état visuel subtil des cartes déjà lues (opacité réduite, typo mutée).
|
||||
4. **Complétion** : Un "Swipe down" ou le bouton retour referme l'article en le réduisant vers sa position initiale dans la grille.
|
||||
|
||||
## Visual Design Foundation
|
||||
|
||||
### Color System: "Paper Dark"
|
||||
|
||||
Une palette élégante et chaleureuse inspirée de l'édition premium en mode sombre.
|
||||
|
||||
- **Background** : `#1a1a1a` (Deep Carbon).
|
||||
- **Surface** : `#262626` (Warm Charcoal).
|
||||
- **Primary/Accent** : `#d4af37` (Soft Gold) ou `#eab308` (Saffron) pour les favoris et actions clés.
|
||||
- **Text Primary** : `#e5e5e5` (Off-white) pour réduire la fatigue oculaire.
|
||||
- **Text Secondary** : `#a3a3a3` (Muted Grey).
|
||||
|
||||
### Typography System
|
||||
|
||||
Un mariage entre modernité technique et tradition littéraire.
|
||||
|
||||
- **UI & Interface** : *Inter* (Sans-serif) - Clair, précis, moderne.
|
||||
- **Contenu Articles** : *Lora* ou *Merriweather* (Serif) - Excellente lisibilité pour les longs textes, apporte le côté "Livre".
|
||||
- **Titres Cards** : *Playfair Display* (Serif) - Pour l'impact visuel "Magazine".
|
||||
|
||||
### Spacing & Layout Foundation
|
||||
|
||||
- **Base Grid** : Système de 8px pour une cohérence parfaite.
|
||||
- **Densité** : "Airy & Spacious". Marges généreuses (gap de 2rem minimum on desktop) pour laisser le contenu respirer.
|
||||
- **Cards Desktop** : Grille asymétrique ou centrée avec images de couverture occupant ~40% de la hauteur de la carte.
|
||||
|
||||
### Accessibility Considerations
|
||||
|
||||
- **Contraste** : Respect strict des ratios WCAG AA pour le texte (`#e5e5e5` on `#1a1a1a`).
|
||||
- **Target Sizes** : Zones de tap de 44px minimum sur mobile.
|
||||
- **Focus States** : Bordures dorées (`#d4af37`) pour la navigation au clavier.
|
||||
|
||||
## Design Direction Decision
|
||||
|
||||
### Design Directions Context
|
||||
|
||||
Exploration d'un showcase interactif présentant des variations de cartes (Hero Image vs Typo Focused), de layouts (Grid vs List), et l'intégration des polices Serif pour le confort de lecture.
|
||||
|
||||
### Final Chosen Direction
|
||||
|
||||
**Magazine Premium "Paper Dark"**. Cette direction privilégie une grille de cartes riche avec des images de couverture fortes, une typographie contrastée mêlant Sans-serif pour l'UI et Serif pour le contenu, et un thème sombre chaleureux (Deep Carbon/Gold).
|
||||
|
||||
### Selection Rationale
|
||||
|
||||
Ce choix valide l'objectif de "Lecture Plaisir" en transformant des flux techniques en un objet éditorial élégant. Le système de cartes asymétriques brise la monotonie tout en restant performant et responsive.
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
Utilisation de CSS Grid Tailwind pour le layout dynamique, Framer Motion (optionnel selon perf) pour l'expansion des cartes, et intégration des Google Fonts sélectionnées pour l'identité typographique.
|
||||
|
||||
## User Journey Flows
|
||||
|
||||
### 1. Le Rituel de Michael (Rattrapage Quotidien)
|
||||
|
||||
Ce parcours est optimisé pour la vitesse de lecture et la satisfaction visuelle.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Dashboard Zen] -->|Scroll & Scan| B{Article Intéressant ?}
|
||||
B -->|Oui| C[Tap sur Article Card]
|
||||
C -->|Expansion Animation| D[Reader View Premium]
|
||||
D -->|Lecture Focus| E[Fin de l'Article]
|
||||
E -->|Swipe Down / Back| F[Retour Grille]
|
||||
F -->|Marquage Lu Visuel| G[Satisfaction Progressive]
|
||||
G -->|Toutes les cartes lues| H[Etat 'All Read' Gratifiant]
|
||||
```
|
||||
|
||||
### 2. L'Émerveillement de Paul (Onboarding Magique)
|
||||
|
||||
Paul doit transformer son chaos de flux en un magazine élégant en une seule étape.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Page d'Accueil] -->|Login/Register| B[Import OPML]
|
||||
B -->|Processing Instantané| C[Premier Dashboard]
|
||||
C -->|Effet Wow| D[Grille Magazine Générée]
|
||||
D -->|Exploration| E[Première Lecture Élégante]
|
||||
```
|
||||
|
||||
### 3. Le Relais sans Couture (Sync Multi-Device)
|
||||
|
||||
Garantir que l'état de lecture est universel et sans friction latente.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant M as Mobile Michael
|
||||
participant S as Serveur FlowReader
|
||||
participant D as Desktop Michael
|
||||
|
||||
M->>S: Marque Lu (WebSocket)
|
||||
S-->>D: Broadcast 'article_read'
|
||||
D-->>D: Transition Visuelle Douce
|
||||
Note over D: Sync en < 100ms
|
||||
```
|
||||
|
||||
### Journey Patterns Context
|
||||
|
||||
- **Pattern "Expansion/Collapse"** : Toutes les entrées et sorties de contenu utilisent le même modèle mental de zoom sur la carte.
|
||||
- **Pattern "Feedback de Complétion"** : Chaque article lu est visuellement estompé dans la grille, renforçant le sentiment de progression.
|
||||
|
||||
### Flow Optimization Principles Detail
|
||||
|
||||
- **Zero-Latency** : Les actions de marquage lu sont optimistes dans l'UI (feedback immédiat avant réponse serveur).
|
||||
- **Focus Unitaire** : Une seule action majeure par écran pour réduire la charge cognitive et respecter la "Clarté Monacale".
|
||||
|
||||
## Component Strategy
|
||||
|
||||
### Design System Components Detail
|
||||
|
||||
L'utilisation de **Tailwind CSS v3** permet d'utiliser les fondations suivantes :
|
||||
|
||||
- **Layout** : Grilles CSS dynamiques (`grid-cols`), conteneurs `prose` customisés.
|
||||
- **Bases** : Boutons, formulaires d'import, navigation sidebar.
|
||||
- **Feedback** : Notifications WebSocket (Toast alerts légères).
|
||||
|
||||
### Custom Components Detail
|
||||
|
||||
#### La Carte "Edition"
|
||||
|
||||
- **Purpose** : Afficher un article dans la grille avec un impact éditorial fort.
|
||||
- **Anatomy** : Box container, Cover Image (centrée ou hero), Title (Playfair Display), Feed Source (Inter uppercase), Date/Read Time.
|
||||
- **States** : Default, Hover (subtile élévation), Read (opacité réduite, texte muté).
|
||||
|
||||
#### Le "Livre" (Reader View)
|
||||
|
||||
- **Purpose** : Vue immersive plein écran pour la lecture profonde.
|
||||
- **Anatomy** : Header large avec image, Titre Serif (italique possible), Corps de texte centré (max-width 65ch), Actions de bas d'article (Favorite, Share).
|
||||
- **Accessibility** : Navigation clavier complète, contrastes WCAG AA.
|
||||
|
||||
#### Le Dashboard "Zen"
|
||||
|
||||
- **Purpose** : Organisation spatiale des cartes.
|
||||
- **Usage** : Grille asymétrique simulant une mise en page magazine, s'adaptant à la taille d'écran.
|
||||
|
||||
### Component Implementation Strategy Detail
|
||||
|
||||
Les composants seront construits comme des fonctions React atomiques. Le style sera géré via des classes Tailwind pour éviter toute latence d'injection CSS. L'animation d'expansion sera implémentée en priorité via des transitions CSS natives pour minimiser l'usage de RAM.
|
||||
|
||||
### Implementation Roadmap Detail
|
||||
|
||||
1. **Phase 1 : Cœur Visuel** : Développement de la Carte et du Reader View.
|
||||
2. **Phase 2 : Navigation & Layout** : Dashboard asymétrique et Sidebar.
|
||||
3. **Phase 3 : Polissage** : Animations de transition et micro-feedbacks.
|
||||
|
||||
## UX Consistency Patterns
|
||||
|
||||
### Button Hierarchy
|
||||
|
||||
- **Primaire (Or)** : Actions transformatrices (Lire, Marquer tout lu). Style : Fond Or (`#d4af37`), texte sombre.
|
||||
- **Secondaire (Warm Charcoal)** : Actions de gestion (Favoris, Partager). Style : Bordure Or, fond sombre (`#262626`).
|
||||
- **Tertiaire (Ghost)** : Actions de navigation/système (Paramètres, Import). Style : Texte gris neutre, pas de fond.
|
||||
|
||||
### Feedback Patterns
|
||||
|
||||
- **Action Optimiste** : L'état visuel (ex: icône 'lu') change instantanément dans l'UI, avant la confirmation serveur.
|
||||
- **WebSocket Broadcast** : Feedback subtil (changement d'opacité de la carte) lors d'une mise à jour depuis un autre terminal, sans interrompre la lecture en cours.
|
||||
|
||||
### Navigation Patterns
|
||||
|
||||
- **Seamless Grid-to-Reader** : Expansion centrée. L'utilisateur ne perd jamais le contexte visuel de la source de l'information.
|
||||
- **Swipe-to-Go-Back** : Sur mobile, un geste de swipe vers le bas ou vers la gauche permet de fermer le lecteur et retrouver la grille.
|
||||
|
||||
### Zen States (Empty States)
|
||||
|
||||
- **Etat "Tout Lu"** : Message de gratification ("Votre esprit est libre"), accompagné d'un visuel minimaliste ou d'une citation inspirante pour marquer la fin de la séance de rattrapage.
|
||||
|
||||
### Loading Patterns
|
||||
|
||||
- **Skeleton Cards** : Chargement asynchrone des cartes utilisant des blocs de couleur respectant les `aspect-ratio` pour éviter tout saut de layout ("layout shift") pendant l'injection des images.
|
||||
|
||||
## Responsive Design & Accessibility
|
||||
|
||||
### Responsive Strategy
|
||||
|
||||
L'expérience FlowReader s'adapte pour garantir le confort de lecture sur tous les supports, en privilégiant une approche **Mobile-First** pour l'interaction et **Desktop Premium** pour la mise en page.
|
||||
|
||||
- **Mobile** : Priorité à l'utilisation à une seule main. Les contrôles sont placés en bas (Bottom Nav). Navigation par gestes (*Swipe down*) pour quitter la lecture.
|
||||
- **Tablette** : Passage à une grille à 2 colonnes asymétriques. Optimisation des zones de tap pour le tactile.
|
||||
- **Desktop** : Grille magazine large avec marges généreuses (2rem+). Menu latéral escamotable pour maximiser l'espace dédié au contenu.
|
||||
|
||||
### Breakpoint Strategy
|
||||
|
||||
Utilisation des breakpoints standard de Tailwind pour une compatibilité maximale :
|
||||
|
||||
- **Mobile** : `< 768px` (Sm/Md) - Layout 1 colonne.
|
||||
- **Tablette** : `768px - 1024px` (Lg) - Layout 2 colonnes.
|
||||
- **Desktop** : `> 1024px` (Xl/2Xl) - Layout multi-colonnes aéré.
|
||||
|
||||
### Accessibility Strategy
|
||||
|
||||
**Niveau WCAG AA** visé pour l'ensemble de l'interface.
|
||||
|
||||
- **Contrastes** : Vérification systématique des ratios pour le texte (`#e5e5e5` sur `#1a1a1a`).
|
||||
- **Typographie** : Tailles de police généreuses (min 16px sur mobile, 18px-20px sur desktop pour le corps de texte).
|
||||
- **Navigation Clavier** : Focus states hautement visibles avec halos dorés (`box-shadow: 0 0 10px #d4af37`).
|
||||
- **Touch Targets** : Zones d'interaction de 44x44px minimum.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Audit Automatisé** : Utilisation d'outils type Lighthouse/Axe pour détecter les erreurs de contraste et de structure ARIA.
|
||||
- **Audit Manuel** : Test de navigation complète au clavier et vérification du rendu sur terminaux réels (iOS/Android).
|
||||
|
||||
### Implementation Guidelines
|
||||
|
||||
- **Semantic HTML** : Utilisation stricte des balises `<article>`, `<header>`, `<nav>`, `<footer>`.
|
||||
- **CSS Clamp** : Utilisation de `clamp()` pour une typographie fluide qui s'adapte sans paliers brusques.
|
||||
- **Optimistic UI** : Réduction de la latence perçue pour améliorer l'expérience des utilisateurs sur réseaux mobiles lents.
|
||||
|
||||
<!-- UX design content will be appended sequentially through collaborative workflow steps -->
|
||||
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "https://excalidraw.com",
|
||||
"elements": [
|
||||
{
|
||||
"id": "screen-1-bg",
|
||||
"type": "rectangle",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"roundness": null,
|
||||
"locked": true
|
||||
},
|
||||
{
|
||||
"id": "screen-1-header",
|
||||
"type": "rectangle",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 80,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#262626",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "logo-text",
|
||||
"type": "text",
|
||||
"x": 40,
|
||||
"y": 25,
|
||||
"width": 150,
|
||||
"height": 30,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"text": "FlowReader",
|
||||
"fontSize": 24,
|
||||
"fontFamily": 2,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"containerId": null,
|
||||
"originalText": "FlowReader"
|
||||
},
|
||||
{
|
||||
"id": "card-1",
|
||||
"type": "rectangle",
|
||||
"x": 100,
|
||||
"y": 140,
|
||||
"width": 300,
|
||||
"height": 400,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#262626",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"roundness": { "type": 3, "value": 8 }
|
||||
},
|
||||
{
|
||||
"id": "card-1-img",
|
||||
"type": "rectangle",
|
||||
"x": 100,
|
||||
"y": 140,
|
||||
"width": 300,
|
||||
"height": 200,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"fillStyle": "cross-hatch",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "dashed",
|
||||
"roughness": 1,
|
||||
"opacity": 50,
|
||||
"roundness": { "type": 3, "value": 8 }
|
||||
},
|
||||
{
|
||||
"id": "card-1-title",
|
||||
"type": "text",
|
||||
"x": 120,
|
||||
"y": 360,
|
||||
"width": 260,
|
||||
"height": 60,
|
||||
"strokeColor": "#e5e5e5",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"text": "L'Art de la Lecture Digitale",
|
||||
"fontSize": 20,
|
||||
"fontFamily": 2,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"originalText": "L'Art de la Lecture Digitale"
|
||||
},
|
||||
{
|
||||
"id": "card-2",
|
||||
"type": "rectangle",
|
||||
"x": 450,
|
||||
"y": 140,
|
||||
"width": 650,
|
||||
"height": 280,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#262626",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"roundness": { "type": 3, "value": 8 }
|
||||
},
|
||||
{
|
||||
"id": "card-2-title",
|
||||
"type": "text",
|
||||
"x": 470,
|
||||
"y": 160,
|
||||
"width": 610,
|
||||
"height": 40,
|
||||
"strokeColor": "#e5e5e5",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"text": "Titre Hero : Le Futur du RSS en 2026",
|
||||
"fontSize": 28,
|
||||
"fontFamily": 2,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"originalText": "Titre Hero : Le Futur du RSS en 2026"
|
||||
},
|
||||
{
|
||||
"id": "screen-2-bg",
|
||||
"type": "rectangle",
|
||||
"x": 1400,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "reader-container",
|
||||
"type": "rectangle",
|
||||
"x": 1650,
|
||||
"y": 0,
|
||||
"width": 700,
|
||||
"height": 800,
|
||||
"strokeColor": "#262626",
|
||||
"backgroundColor": "#262626",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "reader-hero-img",
|
||||
"type": "rectangle",
|
||||
"x": 1650,
|
||||
"y": 0,
|
||||
"width": 700,
|
||||
"height": 300,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"fillStyle": "cross-hatch",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "dashed",
|
||||
"roughness": 1,
|
||||
"opacity": 50
|
||||
},
|
||||
{
|
||||
"id": "reader-title",
|
||||
"type": "text",
|
||||
"x": 1690,
|
||||
"y": 340,
|
||||
"width": 620,
|
||||
"height": 50,
|
||||
"strokeColor": "#e5e5e5",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"text": "L'Art de la Lecture Digitale",
|
||||
"fontSize": 32,
|
||||
"fontFamily": 2,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"originalText": "L'Art de la Lecture Digitale"
|
||||
},
|
||||
{
|
||||
"id": "reader-body",
|
||||
"type": "text",
|
||||
"x": 1690,
|
||||
"y": 420,
|
||||
"width": 620,
|
||||
"height": 300,
|
||||
"strokeColor": "#a3a3a3",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"text": "Le contenu de l'article s'affiche ici avec une typographie Serif large. L'interlignage est généreux pour favoriser le focus profond. \n\nPas de bannières publicitaires, pas de menus latéraux bruyants. Juste le texte et vous.\n\nFlowReader s'efface pour laisser place à la pensée.",
|
||||
"fontSize": 18,
|
||||
"fontFamily": 2,
|
||||
"textAlign": "left",
|
||||
"verticalAlign": "top",
|
||||
"originalText": "Le contenu de l'article s'affiche ici avec une typographie Serif large. L'interlignage est généreux pour favoriser le focus profond. \n\nPas de bannières publicitaires, pas de menus latéraux bruyants. Juste le texte et vous.\n\nFlowReader s'efface pour laisser place à la pensée."
|
||||
},
|
||||
{
|
||||
"id": "reader-close",
|
||||
"type": "ellipse",
|
||||
"x": 2320,
|
||||
"y": 40,
|
||||
"width": 40,
|
||||
"height": 40,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100
|
||||
},
|
||||
{
|
||||
"id": "screen-3-bg",
|
||||
"type": "rectangle",
|
||||
"x": 2800,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 0,
|
||||
"opacity": 100,
|
||||
"roundness": null
|
||||
},
|
||||
{
|
||||
"id": "import-zone",
|
||||
"type": "rectangle",
|
||||
"x": 3100,
|
||||
"y": 200,
|
||||
"width": 600,
|
||||
"height": 300,
|
||||
"strokeColor": "#d4af37",
|
||||
"backgroundColor": "#262626",
|
||||
"fillStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"strokeStyle": "dashed",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"roundness": { "type": 3, "value": 16 }
|
||||
},
|
||||
{
|
||||
"id": "import-text",
|
||||
"type": "text",
|
||||
"x": 3250,
|
||||
"y": 320,
|
||||
"width": 300,
|
||||
"height": 60,
|
||||
"strokeColor": "#a3a3a3",
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"strokeWidth": 1,
|
||||
"strokeStyle": "solid",
|
||||
"roughness": 1,
|
||||
"opacity": 100,
|
||||
"text": "Déposez votre fichier OPML ici\n(Import Paul)",
|
||||
"fontSize": 20,
|
||||
"fontFamily": 2,
|
||||
"textAlign": "center",
|
||||
"verticalAlign": "top",
|
||||
"originalText": "Déposez votre fichier OPML ici\n(Import Paul)"
|
||||
}
|
||||
],
|
||||
"appState": {
|
||||
"viewBackgroundColor": "#1a1a1a",
|
||||
"gridSize": 20,
|
||||
"theme": "dark"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
Generated
+679
@@ -16,19 +16,36 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -1369,6 +1386,277 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"enhanced-resolve": "^5.18.3",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.30.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
|
||||
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
||||
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
|
||||
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
|
||||
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
|
||||
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
|
||||
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
|
||||
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
|
||||
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
|
||||
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.0",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
|
||||
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
|
||||
"integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.1.18",
|
||||
"@tailwindcss/oxide": "4.1.18",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
@@ -1837,6 +2125,43 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.24",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
|
||||
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.1",
|
||||
"caniuse-lite": "^1.0.30001766",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -2041,6 +2366,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
@@ -2048,6 +2383,20 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.19.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
@@ -2387,6 +2736,20 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2438,6 +2801,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -2532,6 +2902,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -2623,6 +3003,267 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -2656,6 +3297,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -2841,6 +3492,13 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -3066,6 +3724,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
||||
@@ -18,14 +18,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -3,54 +3,70 @@ 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;
|
||||
onToggleRead: (id: string, is_read: boolean) => void;
|
||||
onToggleFavorite: (id: string) => 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'
|
||||
})
|
||||
: '';
|
||||
// Simulate a "reading time" based on typical RSS snippet length
|
||||
const readTime = Math.max(1, Math.ceil((article.content?.length || 500) / 1000));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`article-card ${article.is_read ? 'read' : 'unread'}`}
|
||||
<article
|
||||
className={`magazine-card group cursor-pointer flex flex-col h-full bg-carbon-light transition-all duration-500 overflow-hidden relative shadow-lg hover:gold-glow ${!article.is_read ? 'border-gold/30' : 'border-transparent opacity-90'
|
||||
}`}
|
||||
onClick={() => onClick(article)}
|
||||
>
|
||||
<div className="article-header">
|
||||
<span className="feed-title">{article.feed_title}</span>
|
||||
<span className="published-date">{publishedDate}</span>
|
||||
<div className="aspect-[16/10] overflow-hidden bg-carbon-dark relative">
|
||||
{/* Fallback pattern for articles without images */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-carbon-light to-carbon-dark opacity-40 group-hover:opacity-20 transition-opacity" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-10 group-hover:scale-110 transition-transform duration-700">
|
||||
<span className="text-4xl font-serif text-gold underline italic">F</span>
|
||||
</div>
|
||||
|
||||
{!article.is_read && (
|
||||
<div className="absolute top-4 right-4 w-2 h-2 bg-gold rounded-full gold-glow animate-pulse z-10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="article-title">{article.title}</h3>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-gold font-bold truncate max-w-[70%]">
|
||||
{article.feed_title || 'Source'}
|
||||
</span>
|
||||
<span className="text-[10px] text-paper-muted uppercase tracking-wider font-sans">
|
||||
{readTime} MIN READ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{article.summary && (
|
||||
<p className="article-excerpt">
|
||||
{article.summary.replace(/<[^>]*>/g, '').substring(0, 150)}...
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-xl font-serif text-paper-white group-hover:text-gold transition-colors duration-300 leading-tight mb-4 flex-1">
|
||||
{article.title}
|
||||
</h2>
|
||||
|
||||
<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 className="flex items-center justify-between pt-4 border-t border-carbon-dark/50">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(article.id);
|
||||
}}
|
||||
className={`text-xl transition-all duration-300 transform hover:scale-125 ${article.is_favorite ? 'text-gold' : 'text-paper-muted hover:text-gold opacity-30 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{article.is_favorite ? '★' : '☆'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleRead(article.id, article.is_read);
|
||||
}}
|
||||
className={`text-[10px] uppercase tracking-widest font-bold transition-all duration-300 ${article.is_read ? 'text-paper-muted hover:text-paper-white' : 'text-gold hover:brightness-125'
|
||||
}`}
|
||||
>
|
||||
{article.is_read ? 'Lu' : 'Marquer lu'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -3,52 +3,86 @@ import type { Article } from '../api/articles';
|
||||
interface ReaderViewProps {
|
||||
article: Article;
|
||||
onClose: () => void;
|
||||
onToggleFavorite: (id: string, currentStatus: boolean) => void;
|
||||
onToggleFavorite: (id: string) => 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>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-sm flex justify-center overflow-y-auto animate-in fade-in duration-500"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-reading bg-carbon-light min-h-screen my-0 md:my-12 shadow-2xl relative animate-in slide-in-from-bottom duration-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Progress bar simulation */}
|
||||
<div className="sticky top-0 left-0 right-0 h-1 bg-gold/10 z-20">
|
||||
<div className="h-full bg-gold w-1/3 gold-glow shadow-gold" />
|
||||
</div>
|
||||
|
||||
<article className="reader-content">
|
||||
<div className="meta">
|
||||
<span className="feed-name">{article.feed_title}</span>
|
||||
<span className="date">{publishedDate}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-8 -right-16 hidden md:flex w-10 h-10 items-center justify-center rounded-full border border-gold/30 text-gold hover:bg-gold hover:text-carbon transition-all duration-300"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h1 className="title">{article.title}</h1>
|
||||
<div className="p-8 md:p-16">
|
||||
<header className="mb-12">
|
||||
<div className="flex items-center space-x-3 text-gold text-xs uppercase tracking-[0.3em] font-bold mb-6">
|
||||
<span>{article.feed_title}</span>
|
||||
<span className="opacity-30">•</span>
|
||||
<span>{article.published_at ? new Date(article.published_at).toLocaleDateString() : 'Aujourd\'hui'}</span>
|
||||
</div>
|
||||
|
||||
{article.author && <p className="author">Par {article.author}</p>}
|
||||
<h1 className="text-4xl md:text-6xl font-serif text-paper-white leading-[1.1] mb-8 italic">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center justify-between py-6 border-y border-carbon-dark/50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => onToggleFavorite(article.id)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-full border transition-all ${article.is_favorite
|
||||
? 'bg-gold text-carbon border-gold'
|
||||
: 'border-gold/30 text-gold hover:bg-gold/10'
|
||||
}`}
|
||||
>
|
||||
<span>{article.is_favorite ? '★' : '☆'}</span>
|
||||
<span className="text-xs uppercase tracking-widest font-bold">
|
||||
{article.is_favorite ? 'Favori' : 'Ajouter'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button className="p-2 text-paper-muted hover:text-gold transition-colors">
|
||||
📤
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs uppercase tracking-widest font-bold text-gold hover:underline"
|
||||
>
|
||||
Lire sur le site original
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="content-body"
|
||||
dangerouslySetInnerHTML={{ __html: article.content || article.summary || '' }}
|
||||
className="prose prose-invert prose-gold max-w-none text-paper-white/90 text-xl font-reading leading-magazine space-y-8"
|
||||
dangerouslySetInnerHTML={{ __html: article.content || '<p>Aucun contenu disponible pour cet article.</p>' }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<footer className="mt-20 pt-12 border-t border-carbon-dark/50 text-center pb-20">
|
||||
<div className="text-gold text-3xl font-serif mb-4 italic italic-gold">F</div>
|
||||
<p className="text-paper-muted text-sm font-sans tracking-wide">
|
||||
FlowReader Magazine — Immersion Totale.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { feedsApi } from '../api/feeds';
|
||||
import type { Feed } from '../api/feeds';
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectFeed: (feedId: string | null) => void;
|
||||
@@ -8,58 +7,76 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
|
||||
const { data: feeds, isLoading } = useQuery({
|
||||
const { data: feeds } = useQuery({
|
||||
queryKey: ['feeds'],
|
||||
queryFn: feedsApi.list,
|
||||
queryFn: () => feedsApi.list(),
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<nav className="sidebar-nav">
|
||||
<aside className="w-64 bg-carbon-light border-r border-carbon-dark h-screen flex flex-col transition-all duration-300 shrink-0">
|
||||
<div className="p-6">
|
||||
<h1 className="text-gold text-2xl font-serif tracking-tight">FlowReader</h1>
|
||||
<p className="text-paper-muted text-[10px] mt-1 uppercase tracking-[0.2em] font-sans">Magazine Instantané</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
|
||||
<button
|
||||
className={`nav-item ${selectedFeedId === null ? 'active' : ''}`}
|
||||
onClick={() => onSelectFeed(null)}
|
||||
className={`w-full text-left px-4 py-3 rounded-md transition-all duration-200 flex items-center space-x-3 group ${selectedFeedId === null
|
||||
? 'bg-gold/10 text-gold border-l-4 border-gold'
|
||||
: 'text-paper-white hover:bg-carbon-dark'
|
||||
}`}
|
||||
>
|
||||
<span className="icon">📥</span>
|
||||
<span className="label">Tous les flux</span>
|
||||
<span className="text-lg group-hover:scale-110 transition-transform">📰</span>
|
||||
<span className="font-medium">Nouveautés</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`nav-item ${selectedFeedId === 'favorites' ? 'active' : ''}`}
|
||||
onClick={() => onSelectFeed('favorites')}
|
||||
className={`w-full text-left px-4 py-3 rounded-md transition-all duration-200 flex items-center space-x-3 group ${selectedFeedId === 'favorites'
|
||||
? 'bg-gold/10 text-gold border-l-4 border-gold'
|
||||
: 'text-paper-white hover:bg-carbon-dark'
|
||||
}`}
|
||||
>
|
||||
<span className="icon">★</span>
|
||||
<span className="label">Favoris</span>
|
||||
<span className="text-lg group-hover:scale-110 transition-transform">⭐</span>
|
||||
<span className="font-medium">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 className="pt-8 pb-2 px-4">
|
||||
<span className="text-paper-muted text-[10px] uppercase tracking-[0.3em] font-bold">Mes Flux</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{feeds?.map((feed) => (
|
||||
<button
|
||||
key={feed.id}
|
||||
onClick={() => onSelectFeed(feed.id)}
|
||||
className={`w-full text-left px-4 py-2 rounded-md transition-all duration-200 truncate group flex justify-between items-center ${selectedFeedId === feed.id
|
||||
? 'bg-gold/5 text-gold'
|
||||
: 'text-paper-muted hover:text-paper-white hover:bg-carbon-dark'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="mr-2 opacity-30 group-hover:opacity-100 transition-opacity">#</span>
|
||||
{feed.title}
|
||||
</span>
|
||||
{(feed.unread_count ?? 0) > 0 && (
|
||||
<span className="bg-gold/20 text-gold text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.2rem] text-center">
|
||||
{feed.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button className="add-feed-btn" onClick={() => {/* TODO: Add feed modal */ }}>
|
||||
+ Ajouter un flux
|
||||
</button>
|
||||
<div className="p-6 border-t border-carbon-dark bg-carbon-dark/20">
|
||||
<div className="flex items-center space-x-3 text-paper-muted hover:text-gold cursor-pointer transition-all duration-300 group">
|
||||
<div className="w-8 h-8 rounded-full bg-carbon border border-carbon-light flex items-center justify-center group-hover:border-gold group-hover: gold-glow transition-all">
|
||||
<span className="text-sm">⚙️</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Paramètres</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
+47
-58
@@ -1,68 +1,57 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@import "tailwindcss";
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
@theme {
|
||||
--color-carbon: #1a1a1a;
|
||||
--color-carbon-light: #262626;
|
||||
--color-carbon-dark: #121212;
|
||||
--color-gold: #d4af37;
|
||||
--color-paper-white: #e5e5e5;
|
||||
--color-paper-muted: #a3a3a3;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--font-serif: "Playfair Display", serif;
|
||||
--font-sans: Inter, sans-serif;
|
||||
--font-reading: Lora, serif;
|
||||
|
||||
--line-height-magazine: 1.7;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
@utility gold-glow {
|
||||
box-shadow: 0 0 15px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-carbon text-paper-white font-sans antialiased;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-serif;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
|
||||
a {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.magazine-card {
|
||||
@apply bg-carbon-light border border-carbon-light hover:border-gold transition-all duration-300 rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-gold text-carbon font-medium px-4 py-2 rounded-md hover:brightness-110 transition-all;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border border-gold text-gold px-4 py-2 rounded-md hover:bg-gold/10 transition-all;
|
||||
}
|
||||
}
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap');
|
||||
@@ -64,11 +64,8 @@ export function DashboardPage() {
|
||||
|
||||
const handleArticleClick = async (article: Article) => {
|
||||
try {
|
||||
// Fetch full content and sanitize
|
||||
const fullArticle = await articlesApi.get(article.id);
|
||||
setViewingArticle(fullArticle);
|
||||
|
||||
// Mark as read automatically
|
||||
if (!article.is_read) {
|
||||
toggleReadMutation.mutate({ id: article.id, is_read: false });
|
||||
}
|
||||
@@ -78,56 +75,85 @@ export function DashboardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<div className="flex h-screen bg-carbon overflow-hidden">
|
||||
<Sidebar
|
||||
onSelectFeed={setSelectedFeedId}
|
||||
selectedFeedId={selectedFeedId}
|
||||
/>
|
||||
|
||||
<div className="dashboard-content">
|
||||
<header className="dashboard-header">
|
||||
<h1>{selectedFeedId === 'favorites' ? 'Favoris' : 'Nouveautés'}</h1>
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-carbon overflow-hidden relative">
|
||||
<header className="h-20 border-b border-carbon-dark/50 flex items-center justify-between px-8 bg-carbon/50 backdrop-blur-md z-10 shrink-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-gold text-2xl md:text-3xl font-serif italic truncate">
|
||||
{selectedFeedId === 'favorites' ? 'Mes Favoris' : 'Nouveautés'}
|
||||
</h1>
|
||||
<p className="text-paper-muted text-[10px] uppercase tracking-widest font-bold">
|
||||
{articles?.length || 0} ARTICLES À DÉCOUVRIR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<div className="flex items-center space-x-6 shrink-0">
|
||||
{articles && articles.length > 0 && selectedFeedId !== 'favorites' && (
|
||||
<button
|
||||
className="mark-all-read-btn"
|
||||
className="btn-secondary text-[10px] uppercase tracking-widest font-bold py-2 px-4 border-gold/30 hidden md:block"
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
>
|
||||
{markAllReadMutation.isPending ? 'Chargement...' : 'Tout marquer comme lu'}
|
||||
{markAllReadMutation.isPending ? 'Patientez...' : 'Tout marquer lu'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="user-info">
|
||||
<span>{user?.email}</span>
|
||||
<button onClick={handleLogout} className="logout-btn">
|
||||
Déconnexion
|
||||
</button>
|
||||
<div className="flex items-center space-x-4 border-l border-carbon-dark/50 pl-6">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-paper-white text-xs font-medium truncate max-w-[150px]">{user?.email}</p>
|
||||
<button onClick={handleLogout} className="text-paper-muted hover:text-gold text-[10px] uppercase tracking-tighter transition-colors">
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-gold/10 border border-gold/20 flex items-center justify-center text-gold font-serif text-lg shrink-0">
|
||||
{user?.email?.[0].toUpperCase() || 'M'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="articles-main">
|
||||
<main className="flex-1 overflow-y-auto px-4 md:px-8 py-10">
|
||||
{isLoading ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner"></div>
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4 opacity-50">
|
||||
<div className="w-12 h-12 border-2 border-gold/20 border-t-gold rounded-full animate-spin" />
|
||||
<p className="text-gold text-xs uppercase tracking-widest animate-pulse">Édition en cours...</p>
|
||||
</div>
|
||||
) : articles?.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h2>🎉 Tout est lu !</h2>
|
||||
<p>Revenez plus tard pour de nouveaux articles.</p>
|
||||
<div className="flex flex-col items-center justify-center h-full text-center space-y-6 animate-in fade-in duration-1000">
|
||||
<div className="text-6xl filter grayscale opacity-30">💆♂️</div>
|
||||
<div className="max-w-md">
|
||||
<h2 className="text-paper-white text-2xl font-serif mb-2">Immersion Totale</h2>
|
||||
<p className="text-paper-muted font-reading text-lg">
|
||||
Vous avez épuisé vos flux. Profitez de ce moment de silence.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn-secondary text-[10px] uppercase tracking-[0.2em] font-bold"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['articles'] })}
|
||||
>
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="articles-grid">
|
||||
{articles?.map((article: Article) => (
|
||||
<ArticleCard
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-8">
|
||||
{articles?.map((article: Article, index: number) => (
|
||||
<div
|
||||
key={article.id}
|
||||
article={article}
|
||||
onClick={handleArticleClick}
|
||||
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
|
||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })}
|
||||
/>
|
||||
className={`${index === 0 ? 'md:col-span-2' : ''} animate-in fade-in slide-in-from-bottom-4 duration-500`}
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<ArticleCard
|
||||
article={article}
|
||||
onClick={handleArticleClick}
|
||||
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
|
||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+40
-21
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { authApi, ApiError } from '../api/auth';
|
||||
import { authApi } from '../api/auth';
|
||||
import { ApiError } from '../api/client';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -21,7 +22,7 @@ export function LoginPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Login failed. Please try again.');
|
||||
setError('Échec de la connexion. Veuillez réessayer.');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -33,50 +34,68 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h1>FlowReader</h1>
|
||||
<p className="subtitle">Connectez-vous à votre compte</p>
|
||||
<div className="min-h-screen bg-carbon flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md bg-carbon-light p-10 rounded-xl shadow-2xl border border-carbon-dark/50 animate-in fade-in zoom-in duration-500">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-gold text-4xl font-serif mb-2 italic tracking-tight">FlowReader</h1>
|
||||
<p className="text-paper-muted text-[10px] uppercase tracking-[0.3em] font-bold">Magazine Personnel</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/50 text-red-500 text-xs p-3 rounded-md animate-shake">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="vous@example.com"
|
||||
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
|
||||
placeholder="vous@exemple.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Mot de passe</label>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" title="password" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? 'Connexion...' : 'Se connecter'}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full btn-primary py-4 text-xs uppercase tracking-[0.2em] font-bold mt-4 disabled:opacity-50 disabled:cursor-not-wait"
|
||||
>
|
||||
{loginMutation.isPending ? 'Authentification...' : 'Continuer'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="register-link">
|
||||
Pas encore de compte ?{' '}
|
||||
<a href="/register" onClick={(e) => { e.preventDefault(); navigate('/register'); }}>
|
||||
S'inscrire
|
||||
</a>
|
||||
<p className="mt-8 text-center text-paper-muted text-xs">
|
||||
Nouveau chez FlowReader ?{' '}
|
||||
<button
|
||||
className="text-gold hover:underline font-bold tracking-wide"
|
||||
onClick={() => navigate('/register')}
|
||||
>
|
||||
Créer un compte
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { authApi, ApiError } from '../api/auth';
|
||||
import { authApi } from '../api/auth';
|
||||
import { ApiError } from '../api/client';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -19,7 +20,7 @@ export function RegisterPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Registration failed. Please try again.');
|
||||
setError('L\'inscription a échoué. Veuillez réessayer.');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -37,63 +38,84 @@ export function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h1>FlowReader</h1>
|
||||
<p className="subtitle">Créer un nouveau compte</p>
|
||||
<div className="min-h-screen bg-carbon flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md bg-carbon-light p-10 rounded-xl shadow-2xl border border-carbon-dark/50 animate-in fade-in zoom-in duration-500">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-gold text-4xl font-serif mb-2 italic tracking-tight">FlowReader</h1>
|
||||
<p className="text-paper-muted text-[10px] uppercase tracking-[0.3em] font-bold">Rejoindre l'Édition</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/50 text-red-500 text-xs p-3 rounded-md animate-shake">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="vous@example.com"
|
||||
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
|
||||
placeholder="vous@exemple.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Mot de passe</label>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" title='password' className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirmer le mot de passe</label>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="confirmPassword" title='confirm password' className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
|
||||
Confirmer le mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={registerMutation.isPending}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={registerMutation.isPending}
|
||||
className="w-full btn-primary py-4 text-xs uppercase tracking-[0.2em] font-bold mt-4 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{registerMutation.isPending ? 'Création...' : 'Créer mon compte'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="register-link">
|
||||
Déjà un compte ?{' '}
|
||||
<a href="/login" onClick={(e) => { e.preventDefault(); navigate('/login'); }}>
|
||||
<p className="mt-8 text-center text-paper-muted text-xs">
|
||||
Déjà membre ?{' '}
|
||||
<button
|
||||
className="text-gold hover:underline font-bold tracking-wide"
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Se connecter
|
||||
</a>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
carbon: {
|
||||
DEFAULT: '#1a1a1a',
|
||||
light: '#262626',
|
||||
dark: '#121212',
|
||||
},
|
||||
gold: {
|
||||
DEFAULT: '#d4af37',
|
||||
glow: 'rgba(212, 175, 55, 0.4)',
|
||||
},
|
||||
paper: {
|
||||
white: '#e5e5e5',
|
||||
muted: '#a3a3a3',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
serif: ['"Playfair Display"', 'serif'],
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
reading: ['Lora', 'serif'],
|
||||
},
|
||||
lineHeight: {
|
||||
'magazine': '1.7',
|
||||
},
|
||||
maxWidth: {
|
||||
'reading': '700px',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in new issue
Block a user