RegWatch frontend : React/Vite -> nginx + Docker/CI homelab
Build & Deploy / build (push) Successful in 18s

- URL d'API externalisée (VITE_API_URL bakée au build), plus de localhost en dur
- Dockerfile multi-stage pnpm build -> nginx (fallback SPA)
- docker-compose (Watchtower) + .gitea/workflows/build.yml

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 18:43:29 +02:00
co-authored by Claude Opus 4.8
commit 2307d9a134
49 changed files with 6678 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
.app {
min-height: 100vh;
width: 100%;
background-color: #f8fafc;
display: flex;
flex-direction: column;
}
.contentWrapper {
display: flex;
flex: 1;
min-height: 100vh;
}
.main {
flex: 1;
margin-left: 16rem;
padding: 2rem;
min-height: 100vh;
}
@media (max-width: 768px) {
.main {
margin-left: 0;
}
}
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useState } from "react";
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
import AuthPage from "./pages/AuthPage";
import HomePage from "./pages/Home";
import DocumentsPage from "./pages/Documents";
import IngredientSearchPage from "./pages/IngredientSearchPage";
import RecentUpdatesPage from "./pages/RecentUpdatesPage";
import AboutPage from "./pages/AboutPage";
import ContactPage from "./pages/ContactPage";
import CGU from "./pages/CGU";
import MentionsLegales from "./pages/MentionsLegales";
import Sidebar from "./components/Sidebar";
import Footer from "./components/Footer";
import styles from "./App.module.css";
import { getDocuments, type ApiDocument } from "./services/theapi";
type NavItem =
| "home"
| "ingredient-search"
| "documents"
| "recent-updates"
| "about"
| "contact"
| "cgu"
| "mentions-legales";
interface User {
id: number;
email: string;
full_name: string | null;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
loading: boolean;
}
export default function App() {
const [recentDocuments, setRecentDocuments] = useState<ApiDocument[]>([]);
const [authState, setAuthState] = useState<AuthState>(() => {
const savedToken = localStorage.getItem("authToken");
const savedUser = localStorage.getItem("user");
if (savedToken && savedUser) {
const parsedUser = JSON.parse(savedUser);
return {
user: parsedUser,
isAuthenticated: true,
loading: false,
};
}
return {
user: null,
isAuthenticated: false,
loading: false,
};
});
const location = useLocation();
const navigate = useNavigate();
const handleAuthSuccess = (newToken: string, newUser: User) => {
localStorage.setItem("authToken", newToken);
localStorage.setItem("user", JSON.stringify(newUser));
setAuthState({
user: newUser,
isAuthenticated: true,
loading: false,
});
navigate("/");
};
const handleLogout = () => {
localStorage.removeItem("authToken");
localStorage.removeItem("user");
setAuthState({
user: null,
isAuthenticated: false,
loading: false,
});
navigate("/");
};
const activeNav: NavItem =
location.pathname === "/ingredient-search" ? "ingredient-search"
: location.pathname === "/documents" ? "documents"
: location.pathname === "/recent-updates" ? "recent-updates"
: location.pathname === "/about" ? "about"
: location.pathname === "/contact" ? "contact"
: location.pathname === "/cgu" ? "cgu"
: location.pathname === "/mentions-legales"? "mentions-legales"
: "home";
// LOAD RECENT DOCUMENTS
useEffect(() => {
if (authState.isAuthenticated) {
getDocuments()
.then((data) => {
setRecentDocuments(Array.isArray(data) ? data.slice(0, 4) : []);
})
.catch((err) => {
console.error("API ERROR:", err);
setRecentDocuments([]);
});
}
}, [authState.isAuthenticated]);
const handleNavClick = (item: NavItem) => {
const path =
item === "home" ? "/"
: item === "documents" ? "/documents"
: item === "ingredient-search" ? "/ingredient-search"
: item === "recent-updates" ? "/recent-updates"
: item === "about" ? "/about"
: item === "contact" ? "/contact"
: item === "cgu" ? "/cgu"
: "/mentions-legales";
navigate(path);
};
if (authState.loading) {
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", backgroundColor: "#f8fafc" }}>
<p style={{ color: "#475569", fontSize: "1.125rem" }}>Loading application...</p>
</div>
);
}
// Si pas authentifié, afficher la page de login
if (!authState.isAuthenticated) {
return <AuthPage onAuthSuccess={handleAuthSuccess} />;
}
return (
<div className={styles.app}>
<div className={styles.contentWrapper}>
<Sidebar activeNav={activeNav} onNavClick={handleNavClick} onLogout={handleLogout} user={authState.user} />
<main className={styles.main}>
<Routes>
<Route path="/" element={<HomePage handleNavClick={handleNavClick} recentDocuments={recentDocuments} />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/ingredient-search" element={<IngredientSearchPage />} />
<Route path="/recent-updates" element={<RecentUpdatesPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/cgu" element={<CGU />} />
<Route path="/mentions-legales" element={<MentionsLegales />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</main>
</div>
<Footer onNavClick={handleNavClick} />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+33
View File
@@ -0,0 +1,33 @@
import styles from '../styles/Footer.module.css';
interface FooterProps {
onNavClick?: (page: 'about' | 'contact' | 'cgu' | 'mentions-legales') => void;
}
export default function Footer({ onNavClick }: FooterProps) {
const handleClick = (page: 'about' | 'contact' | 'cgu' | 'mentions-legales') => {
if (onNavClick) {
onNavClick(page);
}
};
return (
<footer className={styles.footer}>
<span className={styles.copyright}>© 2026 RegWatch MedLabs. Tous droits réservés.</span>
<div className={styles.links}>
<button onClick={() => handleClick('about')} className={styles.link} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
À propos
</button>
<button onClick={() => handleClick('contact')} className={styles.link} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
Contact
</button>
<button onClick={() => handleClick('mentions-legales')} className={styles.link} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
Mentions légales
</button>
<button onClick={() => handleClick('cgu')} className={styles.link} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
CGU
</button>
</div>
</footer>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Home, Search, FileText, Clock, LogOut } from 'lucide-react';
import styles from '../styles/Sidebar.module.css';
type NavItem =
| 'home'
| 'ingredient-search'
| 'documents'
| 'recent-updates'
| 'about'
| 'contact'
| 'cgu'
| 'mentions-legales';
interface User {
id: number;
email: string;
full_name: string | null;
}
interface SidebarProps {
activeNav: NavItem;
onNavClick: (item: NavItem) => void;
onLogout?: () => void;
user?: User | null;
}
export default function Sidebar({
activeNav,
onNavClick,
onLogout,
user,
}: SidebarProps) {
const navItems: {
key: NavItem;
label: string;
icon: React.ReactNode;
}[] = [
{ key: 'home', label: 'Home', icon: <Home size={18} /> },
{ key: 'ingredient-search', label: 'Ingredient Search', icon: <Search size={18} /> },
{ key: 'documents', label: 'Documents', icon: <FileText size={18} /> },
{ key: 'recent-updates', label: 'Recent Updates', icon: <Clock size={18} /> },
];
const initials = user
? (user.full_name || user.email)
.split(/[ .@_-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
: '';
return (
<aside className={styles.sidebar}>
<div className={styles.header}>
<h1 className={styles.title}>RegWatch MedLabs</h1>
<p className={styles.subtitle}>Regulatory Monitoring</p>
</div>
<nav className={styles.nav}>
{navItems.map((item) => (
<button
key={item.key}
onClick={() => onNavClick(item.key)}
className={`${styles.navButton} ${
activeNav === item.key
? styles.navButtonActive
: styles.navButtonInactive
}`}
>
<span
className={
activeNav === item.key
? styles.navIconActive
: styles.navIconInactive
}
>
{item.icon}
</span>
{item.label}
</button>
))}
</nav>
{user && (
<div className={styles.userSection}>
<div
className={styles.userAvatar}
title={`${user.full_name || ''} ${user.email}`}
>
{initials}
</div>
</div>
)}
{onLogout && (
<button onClick={onLogout} className={styles.logoutBtn}>
<LogOut size={18} />
Logout
</button>
)}
</aside>
);
}
+120
View File
@@ -0,0 +1,120 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
html,
body,
#root {
min-height: 100%;
}
#root {
width: 100%;
max-width: 100%;
margin: 0;
text-align: left;
border-inline: none;
min-height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: var(--bg);
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+19
View File
@@ -0,0 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(
document.getElementById("root")!
).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+141
View File
@@ -0,0 +1,141 @@
import { Target, Users, Shield, BookOpen } from 'lucide-react';
import styles from '../styles/AboutPage.module.css';
export default function AboutPage() {
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
<div className={styles.header}>
<h1 className={styles.title}>À propos de RegWatch MedLabs</h1>
<p className={styles.subtitle}>Plateforme de veille réglementaire pour les ingrédients cosmétiques</p>
</div>
{/* Mission Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionIcon}>
<Target size={24} />
</div>
<h2 className={styles.sectionTitle}>Notre Mission</h2>
</div>
<p className={styles.sectionText}>
RegWatch MedLabs est une plateforme spécialisée conçue pour aider les experts scientifiques et réglementaires à accéder rapidement aux documents officiels relatifs aux ingrédients cosmétiques.
</p>
<p className={styles.sectionText}>
Notre objectif est de simplifier la veille réglementaire en centralisant les informations provenant des principales autorités sanitaires internationales (SCCS, FDA, EFSA, Commission Européenne, etc.).
</p>
</div>
{/* Pour Qui Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionIcon}>
<Users size={24} />
</div>
<h2 className={styles.sectionTitle}>Pour Qui ?</h2>
</div>
<div className={styles.targetList}>
<div className={styles.targetItem}>
<div className={styles.targetTitle}>
<div className={styles.targetDot}></div>
Toxicologues
</div>
<p className={styles.targetDescription}>
Accès rapide aux évaluations de sécurité et études toxicologiques
</p>
</div>
<div className={styles.targetItem}>
<div className={styles.targetTitle}>
<div className={styles.targetDot}></div>
Consultants Réglementaires
</div>
<p className={styles.targetDescription}>
Suivi des dernières réglementations et mises à jour normatives
</p>
</div>
<div className={styles.targetItem}>
<div className={styles.targetTitle}>
<div className={styles.targetDot}></div>
Responsables Qualité
</div>
<p className={styles.targetDescription}>
Vérification de la conformité des ingrédients cosmétiques
</p>
</div>
<div className={styles.targetItem}>
<div className={styles.targetTitle}>
<div className={styles.targetDot}></div>
Chercheurs
</div>
<p className={styles.targetDescription}>
Accès à la base de données complète pour la recherche scientifique
</p>
</div>
</div>
</div>
{/* Key Features */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionIcon}>
<Shield size={24} />
</div>
<h2 className={styles.sectionTitle}>Fonctionnalités Clés</h2>
</div>
<div className={styles.featuresGrid}>
<div className={styles.featureCard}>
<h3 className={styles.featureTitle}>Recherche par Ingrédient</h3>
<p className={styles.featureDescription}>
Trouvez rapidement tous les documents relatifs à un ingrédient spécifique
</p>
</div>
<div className={styles.featureCard}>
<h3 className={styles.featureTitle}>Base de Données Complète</h3>
<p className={styles.featureDescription}>
Accès à l'ensemble des documents collectés par notre système de veille
</p>
</div>
<div className={styles.featureCard}>
<h3 className={styles.featureTitle}>Suivi des Mises à Jour</h3>
<p className={styles.featureDescription}>
Notifications des nouveaux documents et révisions réglementaires
</p>
</div>
<div className={styles.featureCard}>
<h3 className={styles.featureTitle}>Filtres Avancés</h3>
<p className={styles.featureDescription}>
Filtrez par source, type de document, date de publication
</p>
</div>
</div>
</div>
{/* About Platform */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionIcon}>
<BookOpen size={24} />
</div>
<h2 className={styles.sectionTitle}>RegWatch MedLabs</h2>
</div>
<p className={styles.sectionText}>
Plateforme développée par des experts en réglementation cosmétique pour répondre aux besoins spécifiques des professionnels du secteur.
</p>
<div className={styles.infoBox}>
<div className={styles.infoLines}>
<div className={styles.infoLine}>
<span className={styles.infoLabel}>Secteur :</span> Cosmétique & Réglementation
</div>
<div className={styles.infoLine}>
<span className={styles.infoLabel}>Spécialité :</span> Veille réglementaire scientifique
</div>
<div className={styles.infoLine}>
<span className={styles.infoLabel}>Public cible :</span> Experts toxicologues et consultants réglementaires
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useState } from "react";
import styles from "../styles/AuthPage.module.css";
export default function AuthPage({ onAuthSuccess }: { onAuthSuccess: (token: string, user: { id: number; email: string; full_name: string | null }) => void }) {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
console.log("Tentative de connexion avec:", email);
const response = await fetch(`${API_URL}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
console.log("Status:", response.status);
const data = await response.json();
console.log("Response data:", data);
if (!response.ok) {
throw new Error(data.detail || `Erreur ${response.status}`);
}
const userResponse = await fetch(`${API_URL}/me?token=${data.access_token}`);
const userData = await userResponse.json();
onAuthSuccess(data.access_token, userData);
} catch (err) {
console.error("Error:", err);
setError(err instanceof Error ? err.message : "Erreur lors de la connexion");
} finally {
setLoading(false);
}
};
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const response = await fetch(`${API_URL}/signup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
password,
full_name: fullName,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || "Erreur d'inscription");
}
const userData = await response.json();
// Automatiquement connecter après l'inscription
const loginResponse = await fetch(`${API_URL}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
const loginData = await loginResponse.json();
onAuthSuccess(loginData.access_token, userData);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'inscription");
} finally {
setLoading(false);
}
};
const handleSubmit = isLogin ? handleLogin : handleSignup;
return (
<div className={styles.container}>
<div className={styles.authCard}>
<h1 className={styles.title}>RegWatch</h1>
<p className={styles.subtitle}>Gestion des réglementations alimentaires</p>
<form onSubmit={handleSubmit} className={styles.form}>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.formGroup}>
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="votre@email.com"
/>
</div>
{!isLogin && (
<div className={styles.formGroup}>
<label htmlFor="fullName">Nom complet</label>
<input
type="text"
id="fullName"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Votre nom complet"
/>
</div>
)}
<div className={styles.formGroup}>
<label htmlFor="password">Mot de passe</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
/>
</div>
<button
type="submit"
className={styles.submitBtn}
disabled={loading}
>
{loading ? "Chargement..." : isLogin ? "Se connecter" : "S'inscrire"}
</button>
</form>
<div className={styles.toggleAuth}>
<p>
{isLogin ? "Pas encore de compte ?" : "Déjà inscrit ?"}
{" "}
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError("");
}}
className={styles.toggleBtn}
>
{isLogin ? "S'inscrire" : "Se connecter"}
</button>
</p>
</div>
</div>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { FileText, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import styles from '../styles/CGU.module.css';
export default function CGU() {
return (
<main className={styles.page}>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.titleRow}>
<FileText size={32} className={styles.titleIcon} />
<h1 className={styles.title}>Conditions Générales d'Utilisation (CGU)</h1>
</div>
<p className={styles.lastUpdate}>Dernière mise à jour : 15 avril 2026</p>
</div>
<div className={styles.sections}>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Préambule</h2>
<div className={styles.prose}>
<p>
Les présentes conditions générales d'utilisation (CGU) régissent l'utilisation de la plateforme RegWatch MedLabs. L'accès et
l'utilisation du site impliquent l'acceptation pleine et entière des présentes CGU par l'utilisateur.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 1 - Objet du Service</h2>
<div className={styles.prose}>
<p>
RegWatch MedLabs est une plateforme de veille réglementaire destinée aux professionnels du secteur cosmétique. Le service
permet de :
</p>
<div className={styles.checklist}>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Rechercher des documents réglementaires relatifs aux ingrédients cosmétiques</span>
</div>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Accéder à une base de données de documents officiels</span>
</div>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Suivre les mises à jour réglementaires</span>
</div>
</div>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 2 - Accès au Service</h2>
<div className={styles.prose}>
<p>
Le service est accessible gratuitement à tout utilisateur disposant d'un accès à internet. Tous les frais supportés par l'utilisateur
pour accéder au service (accès internet notamment) sont à sa charge.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 3 - Utilisation du Service</h2>
<div className={styles.prose}>
<p style={{ fontWeight: 600, marginBottom: '8px' }}>L'utilisateur s'engage à :</p>
<div className={styles.checklist}>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Utiliser le service de manière loyale et dans le respect des présentes CGU</span>
</div>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Ne pas utiliser le service à des fins commerciales sans autorisation</span>
</div>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Respecter les droits de propriété intellectuelle</span>
</div>
</div>
<p style={{ fontWeight: 600, marginTop: '16px', marginBottom: '8px' }}>Il est formellement interdit de :</p>
<div className={styles.prohibitList}>
<div className={styles.prohibitItem}>
<XCircle size={16} className={styles.prohibitIcon} />
<span>Extraire ou télécharger massivement des données (scraping)</span>
</div>
<div className={styles.prohibitItem}>
<XCircle size={16} className={styles.prohibitIcon} />
<span>Tenter d'accéder aux parties non publiques du service</span>
</div>
<div className={styles.prohibitItem}>
<XCircle size={16} className={styles.prohibitIcon} />
<span>Perturber ou interrompre le fonctionnement du service</span>
</div>
</div>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 4 - Responsabilité</h2>
<div className={styles.warningBox}>
<AlertCircle size={20} className={styles.warningIcon} />
<div className={styles.warningContent}>
<div className={styles.warningTitle}>Avertissement Important</div>
<div className={styles.warningText}>
Les documents et informations fournis sur cette plateforme sont à titre informatif uniquement. RegWatch MedLabs ne
peut garantir l'exactitude, l'exhaustivité ou l'actualité des informations.
</div>
</div>
</div>
<div className={styles.prose} style={{ marginTop: '16px' }}>
<p>
L'utilisateur est seul responsable de l'utilisation qu'il fait des informations consultées. RegWatch MedLabs ne saurait être tenu
responsable de toute décision prise sur la base des informations disponibles sur la plateforme.
</p>
<p>
RegWatch MedLabs ne garantit pas que le service soit exempt d'erreurs, de virus ou autres composants nuisibles.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 5 - Propriété Intellectuelle</h2>
<div className={styles.prose}>
<p>
Tous les éléments du site (structure, textes, logos, images, etc.) sont protégés par le droit d'auteur. Toute reproduction,
représentation, modification, publication, transmission, dénaturtion, totale ou partielle du site ou de son contenu, par quelque
procédé que ce soit, sans autorisation préalable écrite, est interdite.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 6 - Protection des Données</h2>
<div className={styles.prose}>
<p>
Les données personnelles collectées sont traitées conformément au RGPD. Pour plus d'informations, veuillez consulter notre
<span style={{ color: '#1d4ed8' }}> politique de confidentialité</span>.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 7 - Modification des CGU</h2>
<div className={styles.prose}>
<p>
RegWatch MedLabs se réserve le droit de modifier les présentes CGU à tout moment. Les nouvelles conditions prendront effet
dès leur publication sur le site. Il est conseillé à l'utilisateur de consulter régulièrement les CGU.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 8 - Droit Applicable</h2>
<div className={styles.prose}>
<p>
Les présentes CGU sont régies par le droit français. En cas de litige, et à défaut d'accord amiable, le litige sera porté devant les
tribunaux français compétents.
</p>
</div>
</section>
</div>
</div>
</main>
);
}
+238
View File
@@ -0,0 +1,238 @@
import { useState } from 'react';
import { Mail, Phone, MapPin, Send } from 'lucide-react';
import styles from '../styles/ContactPage.module.css';
export default function ContactPage() {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted:', formData);
setFormData({ name: '', email: '', subject: '', message: '' });
};
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
<div className={styles.header}>
<div className={styles.headerIcon}>
<Mail size={28} />
</div>
<h1 className={styles.title}>Contact</h1>
<p className={styles.subtitle}>Nous sommes à votre écoute pour toute question ou demande d'information</p>
</div>
{/* Main Content */}
<div className={styles.mainContent}>
{/* Contact Info */}
<div className={styles.contactInfo}>
{/* Email */}
<div className={styles.infoSection}>
<h3 className={styles.infoSectionTitle}>
<Mail size={20} className={styles.infoIcon} />
Email
</h3>
<div className={styles.infoContent}>
<a href="mailto:contact@regwatch-medlabs.com" className={styles.infoItemLink}>
<span className={styles.infoItemBold}>contact@regwatch-medlabs.com</span>
</a>
<a href="mailto:support@regwatch-medlabs.com" className={styles.infoItemLink}>
<span className={styles.infoItemBold}>support@regwatch-medlabs.com</span>
</a>
</div>
</div>
{/* Phone */}
<div className={styles.infoSection}>
<h3 className={styles.infoSectionTitle}>
<Phone size={20} className={styles.infoIcon} />
Téléphone
</h3>
<div className={styles.infoContent}>
<div className={styles.infoItem}>
<span className={styles.infoItemBold}>+33 (0)1 XX XX XX XX</span>
</div>
<div className={`${styles.infoItem} ${styles.infoItemSmall}`}>
Du lundi au vendredi, 9h-18h
</div>
</div>
</div>
{/* Address */}
<div className={styles.infoSection}>
<h3 className={styles.infoSectionTitle}>
<MapPin size={20} className={styles.infoIcon} />
Adresse
</h3>
<div className={styles.infoContent}>
<div className={styles.infoItem}>
<span className={styles.infoItemBold}>RegWatch MedLabs</span>
</div>
<div className={styles.infoItem}>[Adresse complète]</div>
<div className={styles.infoItem}>[Code postal] [Ville]</div>
<div className={styles.infoItem}>France</div>
</div>
</div>
{/* Opening Hours */}
<div className={styles.openingHoursBox}>
<h4 className={styles.openingHoursTitle}>Horaires d'ouverture</h4>
<div className={styles.openingHoursList}>
<div>Lundi - Vendredi : 9h00 - 18h00</div>
<div>Samedi - Dimanche : Fermé</div>
</div>
</div>
{/* Technical Support */}
<div className={styles.technicalSupport}>
<h4 className={styles.technicalSupportTitle}>Support Technique</h4>
<p className={styles.technicalSupportText}>
Pour toute assistance technique, merci de préciser :
</p>
<ul className={styles.technicalSupportList}>
<li className={styles.technicalSupportItem}>
<span className={styles.technicalSupportBullet}></span>
<span>Votre navigateur web</span>
</li>
<li className={styles.technicalSupportItem}>
<span className={styles.technicalSupportBullet}></span>
<span>Description du problème</span>
</li>
<li className={styles.technicalSupportItem}>
<span className={styles.technicalSupportBullet}></span>
<span>Captures d'écran si possible</span>
</li>
</ul>
</div>
</div>
{/* Form */}
<div className={styles.formSection}>
<h2 className={styles.formTitle}>Envoyez-nous un message</h2>
<form onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={styles.formGroup}>
<label htmlFor="name" className={`${styles.formLabel} ${styles.formLabelRequired}`}>
Nom complet
</label>
<input
id="name"
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Votre nom"
className={styles.formInput}
required
/>
</div>
<div className={styles.formGroup}>
<label htmlFor="email" className={`${styles.formLabel} ${styles.formLabelRequired}`}>
Email
</label>
<input
id="email"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="votre.email@exemple.com"
className={styles.formInput}
required
/>
</div>
</div>
<div className={`${styles.formGrid} ${styles.formGridFull}`}>
<div className={styles.formGroup}>
<label htmlFor="subject" className={`${styles.formLabel} ${styles.formLabelRequired}`}>
Sujet
</label>
<select
id="subject"
name="subject"
value={formData.subject}
onChange={handleChange}
className={styles.formSelect}
required
>
<option value="">Sélectionnez un sujet</option>
<option value="general">Question générale</option>
<option value="support">Support technique</option>
<option value="partnership">Partenariat</option>
<option value="feedback">Avis et suggestions</option>
<option value="other">Autre</option>
</select>
</div>
</div>
<div className={`${styles.formGrid} ${styles.formGridFull}`}>
<div className={styles.formGroup}>
<label htmlFor="message" className={`${styles.formLabel} ${styles.formLabelRequired}`}>
Message
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Écrivez votre message ici..."
className={styles.formTextarea}
required
></textarea>
</div>
</div>
<p className={styles.disclaimerText}>
* Champs obligatoires. Vos données personnelles sont traitées conformément à notre politique de confidentialité et au RGPD. Elles ne seront utilisées que pour répondre à votre demande.
</p>
<button type="submit" className={styles.submitButton}>
<Send size={18} />
Envoyer le message
</button>
</form>
</div>
</div>
{/* FAQ Section */}
<div className={styles.faqSection}>
<h2 className={styles.faqTitle}>Questions Fréquentes</h2>
<div className={styles.faqItems}>
<div className={styles.faqItem}>
<h3 className={styles.faqQuestion}>Comment rechercher un ingrédient ?</h3>
<p className={styles.faqAnswer}>
Utilisez la barre de recherche sur la page d'accueil ou accédez à la page "Recherche d'Ingrédients" via le menu de navigation.
</p>
</div>
<div className={styles.faqItem}>
<h3 className={styles.faqQuestion}>Les documents sont-ils mis à jour ?</h3>
<p className={styles.faqAnswer}>
Oui, notre système de veille collecte régulièrement les nouveaux documents et mises à jour réglementaires.
</p>
</div>
<div className={styles.faqItem}>
<h3 className={styles.faqQuestion}>Le service est-il gratuit ?</h3>
<p className={styles.faqAnswer}>
Contactez-nous pour connaître nos offres et tarifs adaptés à vos besoins.
</p>
</div>
</div>
</div>
</div>
</div>
);
}
+279
View File
@@ -0,0 +1,279 @@
import { useEffect, useMemo, useState } from "react";
import {
Filter,
ArrowUpDown,
File as FileIcon,
} from "lucide-react";
import styles from "../styles/DocumentsPage.module.css";
import { getDocuments } from "../services/theapi";
type ApiDocument = {
title: string;
ingredient: string;
source: string;
type: string;
date?: string | null;
pdf_url: string;
bold?: boolean;
};
const ITEMS_PER_PAGE = 8;
export default function DocumentsPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [sortBy, setSortBy] = useState("Date (Newest)");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments()
.then((data) => {
setDocuments(Array.isArray(data) ? data : []);
})
.catch((err) => {
console.error("API Error:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(documents.map((d) => d.source))],
[documents]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(documents.map((d) => d.type))],
[documents]
);
const allDates = useMemo(
() => [
"All Dates",
...new Set(
documents.map((d) => String(d.date ?? "").slice(0, 4))
),
],
[documents]
);
const filtered = useMemo(() => {
return documents
.filter((d) => {
const sourceMatch =
sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch =
dateFilter === "All Dates" ||
safeDate.startsWith(dateFilter);
return sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => {
const dateA = String(a.date ?? "");
const dateB = String(b.date ?? "");
const titleA = String(a.title ?? "");
const titleB = String(b.title ?? "");
if (sortBy === "Date (Newest)") return dateB.localeCompare(dateA);
if (sortBy === "Date (Oldest)") return dateA.localeCompare(dateB);
if (sortBy === "Title (A-Z)") return titleA.localeCompare(titleB);
if (sortBy === "Title (Z-A)") return titleB.localeCompare(titleA);
return 0;
});
}, [documents, sourceFilter, typeFilter, dateFilter, sortBy]);
const totalPages = Math.max(
1,
Math.ceil(filtered.length / ITEMS_PER_PAGE)
);
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * ITEMS_PER_PAGE,
safePage * ITEMS_PER_PAGE
);
if (loading) {
return (
<div className={styles.container}>
<p>Loading documents...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h2 className={styles.title}>Document Database</h2>
<p className={styles.description}>
Complete collection of regulatory monitoring documents
</p>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters:</span>
</div>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className={styles.select}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className={styles.select}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className={styles.select}
>
{allDates.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<div className={styles.sortWrapper}>
<ArrowUpDown size={16} />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className={styles.select}
>
<option value="Date (Newest)">Date (Newest)</option>
<option value="Date (Oldest)">Date (Oldest)</option>
<option value="Title (A-Z)">Title (A-Z)</option>
<option value="Title (Z-A)">Title (Z-A)</option>
</select>
</div>
</div>
{/* STATS */}
<div className={styles.statsRow}>
<p className={styles.statsText}>
Total Documents:
<span className={styles.statsNumber}>
{" "}
{filtered.length}
</span>
</p>
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span>Title</span>
<span>Ingredient</span>
<span>Source</span>
<span>Type</span>
<span>Date</span>
<span>PDF</span>
</div>
{paginated.map((doc, index) => (
<div
key={`${doc.title}-${doc.source}-${doc.date ?? index}`}
className={styles.tableRow}
>
<div className={styles.titleCell}>
<FileIcon size={16} />
<span>{doc.title}</span>
</div>
<span>{doc.ingredient}</span>
<span>{doc.source}</span>
<span>{doc.type}</span>
<span>{doc.date}</span>
<div>
<a
href={doc.pdf_url}
target="_blank"
rel="noreferrer"
>
<button className={styles.openButton}>
Open
</button>
</a>
</div>
</div>
))}
</div>
{/* PAGINATION */}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
onClick={() =>
setCurrentPage(Math.max(1, safePage - 1))
}
disabled={safePage === 1}
className={styles.paginationButton}
>
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={
page === safePage
? styles.pageNumberActive
: styles.pageNumberInactive
}
>
{page}
</button>
)
)}
<button
onClick={() =>
setCurrentPage(Math.min(totalPages, safePage + 1))
}
disabled={safePage === totalPages}
className={styles.paginationButton}
>
Next
</button>
</div>
)}
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Search, FileText, TrendingUp, Clock } from 'lucide-react';
import styles from '../styles/HomePage.module.css';
import { openPdf, type ApiDocument } from '../services/theapi';
type NavItem =
| 'home'
| 'ingredient-search'
| 'documents'
| 'recent-updates'
| 'about'
| 'contact'
| 'cgu'
| 'mentions-legales';
interface HomePageProps {
handleNavClick: (item: NavItem) => void;
recentDocuments: ApiDocument[];
}
export default function HomePage({ handleNavClick, recentDocuments }: HomePageProps) {
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* Hero Section */}
<div className={styles.heroSection}>
<h1 className={styles.title}>RegWatch MedLabs</h1>
<p className={styles.subtitle}>Cosmetic Regulatory Monitoring Platform</p>
<p className={styles.description}>
Quickly find official regulatory documents related to cosmetic ingredients
</p>
</div>
{/* Feature Cards */}
<div className={styles.cardsGrid}>
<button onClick={() => handleNavClick('ingredient-search')} className={styles.card}>
<div className={styles.cardIconWrapper}>
<Search size={24} className={styles.cardIcon} />
</div>
<h3 className={styles.cardTitle}>Ingredient Search</h3>
<p className={styles.cardDescription}>Search regulatory documents by ingredient</p>
</button>
<button onClick={() => handleNavClick('documents')} className={styles.card}>
<div className={styles.cardIconWrapper}>
<FileText size={24} className={styles.cardIcon} />
</div>
<h3 className={styles.cardTitle}>All Documents</h3>
<p className={styles.cardDescription}>Browse complete document database</p>
</button>
<button onClick={() => handleNavClick('recent-updates')} className={styles.card}>
<div className={styles.cardIconWrapper}>
<TrendingUp size={24} className={styles.cardIcon} />
</div>
<h3 className={styles.cardTitle}>Recent Updates</h3>
<p className={styles.cardDescription}>Latest regulatory changes</p>
</button>
</div>
{/* Recent Documents */}
<div className={styles.recentSection}>
<div className={styles.sectionHeader}>
<Clock size={20} className={styles.sectionIcon} />
<h2 className={styles.sectionTitle}>Recent Regulatory Documents</h2>
</div>
<div className={styles.documentsList}>
{recentDocuments.map((doc, index) => (
<div
key={doc.id}
className={`${styles.documentItem} ${
index < recentDocuments.length - 1 ? styles.documentItemNotLast : ''
}`}
>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<span><span className={styles.metaLabel}>Ingredient:</span> {doc.ingredient}</span>
<span><span className={styles.metaLabel}>Source:</span> {doc.source}</span>
<span><span className={styles.metaLabel}>Date:</span> {doc.date ?? "—"}</span>
{doc.pdf_url ? (
<button
className={styles.documentButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
) : (
<button className={styles.documentButton} disabled>No PDF</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useMemo, useState } from "react";
import { Search, Filter } from "lucide-react";
import styles from "../styles/IngredientSearchPage.module.css";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
export default function IngredientSearchPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
// FETCH API
useEffect(() => {
getDocuments()
.then((data) => setDocuments(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
// FILTER OPTIONS
const allSources = useMemo(() => [
"All Sources",
...new Set(documents.map((d) => d.source)),
], [documents]);
const allTypes = useMemo(() => [
"All Types",
...new Set(documents.map((d) => d.type)),
], [documents]);
const allDates = useMemo(() => [
"All Dates",
...new Set(documents.map((d) => String(d.date ?? "").slice(0, 4))).values(),
], [documents]);
// FILTERED RESULTS
const filtered = useMemo(() => {
return documents
.filter((d) => {
const queryMatch =
searchQuery === "" ||
d.ingredient.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.title.toLowerCase().includes(searchQuery.toLowerCase());
const sourceMatch = sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch = typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch = dateFilter === "All Dates" || safeDate.startsWith(dateFilter);
return queryMatch && sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => String(b.date ?? "").localeCompare(String(a.date ?? "")));
}, [documents, searchQuery, sourceFilter, typeFilter, dateFilter]);
if (loading) {
return <div className={styles.container}><p>Loading...</p></div>;
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h1 className={styles.title}>Ingredient Search</h1>
<p className={styles.subtitle}>Search cosmetic ingredients and regulatory documents</p>
</div>
{/* SEARCH */}
<div className={styles.searchBarWrapper}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
type="text"
placeholder="Search ingredient..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search size={20} className={styles.searchIcon} />
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterHeader}>
<Filter size={16} className={styles.filterIcon} />
<span>Filters</span>
</div>
<div className={styles.filterControls}>
<select className={styles.select} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
{allSources.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className={styles.select} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
{allTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select className={styles.select} value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}>
{allDates.map((d) => <option key={d} value={d}>{d || "Unknown"}</option>)}
</select>
</div>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span> results found
</div>
{/* DOCUMENTS */}
<div className={styles.documentsList}>
{filtered.length === 0 ? (
<div className={styles.emptyState}>
<p className={styles.emptyMessage}>No documents found.</p>
</div>
) : (
filtered.map((doc) => (
<div key={doc.id} className={styles.documentCard}>
<div className={styles.documentCardFlex}>
<div className={styles.documentContent}>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Ingredient</span>
<span className={styles.metaValue}>{doc.ingredient}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Source</span>
<span className={styles.metaValue}>{doc.source}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>{doc.type}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Date</span>
<span className={styles.metaValue}>{doc.date ?? "—"}</span>
</div>
</div>
</div>
{doc.pdf_url && (
<button
className={styles.openButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { Scale, Building2, Globe, Mail } from 'lucide-react';
import styles from '../styles/MentionsLegales.module.css';
export default function MentionsLegales() {
return (
<main className={styles.page}>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Scale size={32} className={styles.titleIcon} />
<h1 className={styles.title}>Mentions Légales</h1>
</div>
<p className={styles.subtitle}>Informations légales et réglementaires</p>
</div>
<div className={styles.sections}>
<section className={styles.card}>
<div className={styles.cardHeader}>
<Building2 size={20} className={styles.cardIcon} />
<h2 className={styles.cardTitle}>Éditeur du Site</h2>
</div>
<div className={styles.fields}>
<p className={styles.field}><strong>Raison sociale :</strong> RegWatch MedLabs</p>
<p className={styles.field}><strong>Forme juridique :</strong> [À compléter]</p>
<p className={styles.field}><strong>Capital social :</strong> [À compléter]</p>
<p className={styles.field}><strong>Siège social :</strong> [Adresse complète]</p>
<p className={styles.field}><strong>N° SIRET :</strong> [À compléter]</p>
<p className={styles.field}><strong>N° TVA intracommunautaire :</strong> [À compléter]</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Directeur de la Publication</h2>
<div className={`${styles.fields} ${styles.sectionContent}`}>
<p className={styles.field}><strong>Nom :</strong> [À compléter]</p>
<p className={styles.field}><strong>Fonction :</strong> [À compléter]</p>
</div>
</section>
<section className={styles.card}>
<div className={styles.cardHeader}>
<Globe size={20} className={styles.cardIcon} />
<h2 className={styles.cardTitle}>Hébergement</h2>
</div>
<div className={styles.fields}>
<p className={styles.field}><strong>Hébergeur :</strong> [Nom de l'hébergeur]</p>
<p className={styles.field}><strong>Adresse :</strong> [Adresse complète]</p>
<p className={styles.field}><strong>Téléphone :</strong> [Numéro]</p>
<p className={styles.field}><strong>Site web :</strong> [URL]</p>
</div>
</section>
<section className={styles.card}>
<div className={styles.cardHeader}>
<Mail size={20} className={styles.cardIcon} />
<h2 className={styles.cardTitle}>Contact</h2>
</div>
<div className={styles.fields}>
<p className={styles.field}><strong>Email :</strong> contact@regwatch-medlabs.com</p>
<p className={styles.field}><strong>Téléphone :</strong> [À compléter]</p>
<p className={styles.field}><strong>Adresse postale :</strong> [À compléter]</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Propriété Intellectuelle</h2>
<div className={`${styles.prose} ${styles.sectionContent}`}>
<p>
L'ensemble de ce site relève de la législation française et internationale sur le droit d'auteur et la propriété intellectuelle. Tous les
droits de reproduction sont réservés, y compris pour les documents téléchargeables et les représentations iconographiques et
photographiques.
</p>
<p className={styles.highlight}>
La reproduction de tout ou partie de ce site sur un support électronique quel qu'il soit est formellement interdite sauf
autorisation expresse du directeur de la publication.
</p>
<p className={styles.highlight}>
Les marques de RegWatch MedLabs ainsi que les logos figurant sur le site sont des marques déposées. Toute reproduction totale
ou partielle de ces marques ou de ces logos effectuée à partir des éléments du site sans l'autorisation expresse de RegWatch
MedLabs est donc prohibée.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Protection des Données Personnelles</h2>
<div className={`${styles.prose} ${styles.sectionContent}`}>
<p>
Conformément à la loi « Informatique et Libertés » du 6 janvier 1978 modifiée et au Règlement Général sur la Protection des
Données (RGPD), vous disposez d'un droit d'accès, de rectification, de suppression et d'opposition aux données personnelles
vous concernant.
</p>
<p>
Pour exercer ces droits, vous pouvez contacter : contact@regwatch-medlabs.com
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Cookies</h2>
<div className={`${styles.prose} ${styles.sectionContent}`}>
<p>
Ce site peut utiliser des cookies pour améliorer l'expérience utilisateur. Vous pouvez configurer votre navigateur pour refuser les
cookies, mais certaines fonctionnalités du site pourraient ne pas être disponibles.
</p>
</div>
</section>
</div>
</div>
</main>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from "react";
import { TrendingUp, Filter, Eye } from "lucide-react";
import styles from "../styles/RecentUpdatesPage.module.css";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
const ITEMS_PER_PAGE = 8;
export default function RecentUpdatesPage() {
const [updates, setUpdates] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments()
.then((data) => setUpdates(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setUpdates([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(updates.map((u) => u.source))],
[updates]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(updates.map((u) => u.type))],
[updates]
);
const filtered = useMemo(() => {
return updates
.filter((u) => {
const sourceMatch =
sourceFilter === "All Sources" || u.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || u.type === typeFilter;
return sourceMatch && typeMatch;
})
.sort((a, b) =>
String(b.date ?? "").localeCompare(String(a.date ?? ""))
);
}, [updates, sourceFilter, typeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * ITEMS_PER_PAGE,
safePage * ITEMS_PER_PAGE
);
if (loading) {
return (
<div className={styles.container}>
<p>Loading updates...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<div className={styles.headerFlex}>
<TrendingUp size={28} className={styles.headerIcon} />
<h1 className={styles.title}>Recent Updates</h1>
</div>
<p className={styles.subtitle}>
Latest cosmetic regulatory updates and monitoring activity
</p>
</div>
{/* STATS */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<p className={styles.statLabel}>Total Updates</p>
<h2 className={styles.statValue}>{updates.length}</h2>
<div className={`${styles.statIcon} ${styles.statIconBlue}`}>
<TrendingUp size={18} />
</div>
</div>
<div className={styles.statCard}>
<p className={styles.statLabel}>Sources</p>
<h2 className={styles.statValue}>{allSources.length - 1}</h2>
<div className={`${styles.statIcon} ${styles.statIconGreen}`}>
<Filter size={18} />
</div>
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters</span>
</div>
<select
className={styles.select}
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
className={styles.select}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span>{" "}
updates found
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span className={styles.headerCell}>Status</span>
<span className={styles.headerCell}>Title</span>
<span className={styles.headerCell}>Ingredient</span>
<span className={styles.headerCell}>Source</span>
<span className={styles.headerCell}>Type</span>
<span className={styles.headerCell}>Date</span>
<span className={styles.headerCell}>View</span>
</div>
{paginated.map((u, index) => (
<div
key={u.id}
className={`${styles.tableRow} ${
index < paginated.length - 1
? styles.tableRowBorder
: ""
}`}
>
<div>
<span
className={`${styles.statusBadge} ${styles.statusBadgeNew}`}
>
New
</span>
</div>
<div className={`${styles.titleCell} ${styles.cellTruncate}`}>
{u.title}
</div>
<div className={styles.cell}>{u.ingredient}</div>
<div className={styles.cell}>{u.source}</div>
<div className={styles.cell}>{u.type}</div>
<div className={styles.cell}>{u.date ?? "—"}</div>
<div>
{u.pdf_url && (
<button
className={styles.viewButton}
onClick={() => openPdf(u.pdf_url)}
>
<Eye size={14} />
</button>
)}
</div>
</div>
))}
</div>
{/* PAGINATION */}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
className={styles.paginationButton}
disabled={safePage === 1}
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 1))
}
>
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={
page === safePage
? styles.pageNumberActive
: styles.pageNumberInactive
}
>
{page}
</button>
)
)}
<button
className={styles.paginationButton}
disabled={safePage === totalPages}
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
>
Next
</button>
</div>
)}
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
// Injecté au build par Vite (VITE_API_URL). Repli sur le backend local en dev.
export const API_URL = import.meta.env.VITE_API_URL ?? "http://127.0.0.1:8000";
export type ApiDocument = {
id: number;
title: string;
ingredient: string;
source: string;
type: string;
date?: string | null;
pdf_url: string;
};
export async function getDocuments(): Promise<ApiDocument[]> {
const res = await fetch(`${API_URL}/documents`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export function openPdf(pdfUrl: string): void {
window.open(pdfUrl, "_blank", "noopener,noreferrer");
}
+173
View File
@@ -0,0 +1,173 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.header {
margin-bottom: 2rem;
}
.title {
font-size: 2.5rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.75rem;
line-height: 1.05;
}
.subtitle {
font-size: 1.125rem;
color: rgb(75, 85, 99);
line-height: 1.6;
margin: 0;
}
.section {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 2rem;
margin-bottom: 1.5rem;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.sectionIcon {
width: 2.5rem;
height: 2.5rem;
background-color: rgb(219, 234, 254);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: rgb(37, 99, 235);
flex-shrink: 0;
}
.sectionTitle {
font-size: 1.5rem;
font-weight: 600;
color: rgb(17, 24, 39);
}
.sectionText {
color: rgb(75, 85, 99);
line-height: 1.6;
margin-bottom: 1rem;
}
.sectionText:last-child {
margin-bottom: 0;
}
.targetList {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.targetItem {
padding-left: 0;
}
.targetTitle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
}
.targetDot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: rgb(37, 99, 235);
flex-shrink: 0;
}
.targetDescription {
color: rgb(107, 114, 128);
font-size: 0.875rem;
margin-left: 1.5rem;
}
.featuresGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.featureCard {
background-color: rgb(249, 250, 251);
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
}
.featureTitle {
font-size: 1.125rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
}
.featureDescription {
color: rgb(107, 114, 128);
font-size: 0.875rem;
line-height: 1.5;
}
.infoBox {
background-color: rgb(249, 250, 251);
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
}
.infoTitle {
font-size: 1.125rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 1rem;
}
.infoLines {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.infoLine {
color: rgb(75, 85, 99);
font-size: 0.875rem;
}
.infoLabel {
font-weight: 600;
color: rgb(17, 24, 39);
}
@media (max-width: 768px) {
.title {
font-size: 1.875rem;
}
.featuresGrid {
grid-template-columns: 1fr;
}
.section {
padding: 1.5rem;
}
}
+145
View File
@@ -0,0 +1,145 @@
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f8fafc;
padding: 20px;
}
.authCard {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #e2e8f0;
padding: 40px;
width: 100%;
max-width: 400px;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.title {
font-size: 28px;
font-weight: bold;
margin: 0 0 8px 0;
text-align: center;
color: #1a202c;
}
.subtitle {
font-size: 14px;
color: #64748b;
text-align: center;
margin: 0 0 30px 0;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.formGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.formGroup label {
font-weight: 500;
color: #475569;
font-size: 14px;
}
.formGroup input {
padding: 10px 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: #f8fafc;
color: #000;
}
.formGroup input:focus {
outline: none;
border-color: #1a202c;
box-shadow: 0 0 0 3px rgba(26, 32, 44, 0.05);
background-color: white;
}
.formGroup input::placeholder {
color: #94a3b8;
}
.error {
background-color: #fee2e2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 12px;
border-radius: 6px;
font-size: 14px;
text-align: center;
}
.submitBtn {
padding: 12px;
background-color: #1a202c;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.submitBtn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(26, 32, 44, 0.15);
}
.submitBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.toggleAuth {
margin-top: 20px;
text-align: center;
}
.toggleAuth p {
margin: 0;
font-size: 14px;
color: #64748b;
}
.toggleBtn {
background: none;
border: none;
color: #1a202c;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
font-size: 14px;
padding: 0;
margin-left: 4px;
transition: color 0.2s;
}
.toggleBtn:hover {
color: #0f172a;
}
+180
View File
@@ -0,0 +1,180 @@
.page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
background: #f9fafb;
}
.content {
flex: 1;
padding: 40px 40px 32px;
max-width: 960px;
}
.header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 32px;
}
.titleRow {
display: flex;
align-items: center;
gap: 12px;
}
.titleIcon {
color: #1d4ed8;
flex-shrink: 0;
}
.title {
font-size: 2.5rem;
font-weight: 700;
color: #111827;
letter-spacing: -0.02em;
margin: 0;
line-height: 1.05;
}
.lastUpdate {
font-size: 13px;
color: #6b7280;
margin: 0;
padding-left: 2px;
}
.sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 28px 32px;
}
.cardTitle {
font-size: 17px;
font-weight: 700;
color: #111827;
margin: 0 0 12px 0;
}
.prose {
display: flex;
flex-direction: column;
gap: 12px;
}
.prose p {
font-size: 14px;
color: #374151;
line-height: 1.7;
margin: 0;
}
.prose a {
color: #1d4ed8;
text-decoration: none;
}
.prose a:hover {
text-decoration: underline;
}
.checklist {
display: flex;
flex-direction: column;
gap: 10px;
margin: 12px 0;
}
.checkItem {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 14px;
color: #374151;
line-height: 1.6;
}
.checkIcon {
color: #10b981;
flex-shrink: 0;
margin-top: 2px;
}
.prohibitList {
display: flex;
flex-direction: column;
gap: 10px;
margin: 12px 0;
}
.prohibitItem {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 14px;
color: #374151;
line-height: 1.6;
}
.prohibitIcon {
color: #ef4444;
flex-shrink: 0;
margin-top: 2px;
}
.warningBox {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 16px;
margin: 12px 0;
display: flex;
gap: 12px;
}
.warningIcon {
color: #b45309;
flex-shrink: 0;
margin-top: 2px;
}
.warningContent {
display: flex;
flex-direction: column;
gap: 6px;
}
.warningTitle {
font-weight: 700;
color: #b45309;
font-size: 14px;
}
.warningText {
font-size: 14px;
color: #78350f;
line-height: 1.6;
}
.subtitle {
font-size: 1.125rem;
font-weight: 500;
color: #6b7280;
margin: 0.75rem 0 1.5rem 0;
line-height: 1.6;
}
.highlight {
color: #1d4ed8;
}
+330
View File
@@ -0,0 +1,330 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.header {
margin-bottom: 2rem;
}
.headerIcon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
color: rgb(37, 99, 235);
margin-bottom: 1rem;
}
.title {
font-size: 2.5rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.75rem;
line-height: 1.05;
}
.subtitle {
font-size: 1.125rem;
color: rgb(75, 85, 99);
line-height: 1.6;
margin: 0;
}
.mainContent {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 2rem;
margin-bottom: 2rem;
}
.contactInfo {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.infoSection {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
}
.infoSectionTitle {
font-size: 1.125rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.infoIcon {
color: rgb(37, 99, 235);
font-size: 1.25rem;
}
.infoContent {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.infoItem {
color: rgb(75, 85, 99);
font-size: 0.875rem;
line-height: 1.5;
}
.infoItemLink {
color: rgb(37, 99, 235);
text-decoration: none;
transition: color 0.2s;
}
.infoItemLink:hover {
color: rgb(29, 78, 216);
}
.infoItemBold {
font-weight: 600;
color: rgb(17, 24, 39);
}
.infoItemSmall {
color: rgb(107, 114, 128);
font-size: 0.8rem;
}
.openingHoursBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
margin-top: 1rem;
}
.openingHoursTitle {
font-size: 1rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 0.75rem;
}
.openingHoursList {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
color: rgb(75, 85, 99);
}
.technicalSupport {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
}
.technicalSupportTitle {
font-size: 1rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 0.75rem;
}
.technicalSupportText {
color: rgb(75, 85, 99);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.technicalSupportList {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: rgb(75, 85, 99);
font-size: 0.875rem;
}
.technicalSupportItem {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.technicalSupportBullet {
color: rgb(37, 99, 235);
margin-top: 0.25rem;
}
/* Form Styles */
.formSection {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 2rem;
}
.formTitle {
font-size: 1.25rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 1.5rem;
}
.formGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.formGridFull {
grid-column: 1 / -1;
}
.formGroup {
display: flex;
flex-direction: column;
}
.formLabel {
font-size: 0.875rem;
font-weight: 500;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
}
.formLabelRequired::after {
content: ' *';
color: rgb(220, 38, 38);
}
.formInput,
.formSelect,
.formTextarea {
border: 1px solid rgb(229, 231, 235);
border-radius: 0.5rem;
padding: 0.75rem;
font-size: 0.875rem;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
color: rgb(17, 24, 39);
}
.formInput::placeholder,
.formSelect::placeholder,
.formTextarea::placeholder {
color: rgb(156, 163, 175);
}
.formInput:focus,
.formSelect:focus,
.formTextarea:focus {
outline: none;
border-color: rgb(37, 99, 235);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.formTextarea {
resize: vertical;
min-height: 8rem;
}
.disclaimerText {
font-size: 0.75rem;
color: rgb(75, 85, 99);
margin-bottom: 1.5rem;
line-height: 1.5;
}
.submitButton {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: rgb(37, 99, 235);
color: white;
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
font-size: 1rem;
width: fit-content;
}
.submitButton:hover {
background-color: rgb(29, 78, 216);
}
.faqSection {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 2rem;
}
.faqTitle {
font-size: 1.25rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 1.5rem;
}
.faqItems {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.faqItem {
border-left: 3px solid rgb(37, 99, 235);
padding-left: 1rem;
}
.faqQuestion {
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
}
.faqAnswer {
color: rgb(75, 85, 99);
font-size: 0.875rem;
line-height: 1.5;
}
.faqLink {
color: rgb(37, 99, 235);
text-decoration: none;
transition: color 0.2s;
}
.faqLink:hover {
color: rgb(29, 78, 216);
}
@media (max-width: 1024px) {
.mainContent {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.title {
font-size: 1.875rem;
}
.formGrid {
grid-template-columns: 1fr;
}
}
+284
View File
@@ -0,0 +1,284 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.header {
margin-bottom: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
line-height: 1.1;
}
.description {
color: rgb(75, 85, 99);
margin-bottom: 1.5rem;
font-size: 1.125rem;
line-height: 1.6;
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.sortWrapper {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sortIcon {
color: rgb(107, 114, 128);
}
.statsRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.statsText {
font-size: 0.875rem;
color: rgb(55, 65, 81);
}
.statsNumber {
font-weight: 600;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.25rem;
color: rgb(17, 24, 39);
}
.tableRow {
display: grid;
grid-template-columns: minmax(180px,2fr) 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
color: rgb(17, 24, 39);
}
.tableRow span,
.titleCell span,
.titleCell {
color: rgb(17, 24, 39);
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1.2fr 0.8fr 0.6fr;
padding: 1rem 1.25rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.titleCell {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.titleIcon {
color: rgb(156, 163, 175);
margin-top: 0.125rem;
flex-shrink: 0;
}
.titleText {
font-size: 0.875rem;
color: rgb(31, 41, 55);
line-height: 1.5;
}
.ingredientBadge {
display: inline-block;
font-size: 0.75rem;
padding: 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgb(209, 213, 219);
background-color: rgb(243, 244, 246);
}
.ingredientBadgeBold {
font-weight: 600;
border-color: rgb(209, 213, 219);
color: rgb(17, 24, 39);
}
.ingredientBadgeNormal {
border-color: rgb(229, 231, 235);
color: rgb(55, 65, 81);
}
.cell {
font-size: 0.875rem;
color: rgb(75, 85, 99);
}
.cellWhitespace {
white-space: nowrap;
}
.openButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.875rem;
font-weight: 500;
padding: 0.375rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.openButton:hover {
background-color: rgb(29, 78, 216);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
flex-wrap: wrap;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.paginationButton {
padding: 0.45rem 0.85rem;
font-size: 0.85rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: all 0.2s;
}
.paginationButton:hover:not(:disabled) {
background-color: rgb(249, 250, 251);
}
.paginationButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageNumber {
min-width: 2rem;
padding: 0.45rem 0.75rem;
height: 2.25rem;
font-size: 0.875rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
}
.pageNumberActive {
background-color: rgb(37, 99, 235);
color: white;
border-color: rgb(37, 99, 235);
}
.pageNumberInactive {
border: 1px solid rgb(209, 213, 219);
color: rgb(55, 65, 81);
background-color: white;
}
.pageNumberInactive:hover {
background-color: rgb(249, 250, 251);
}
+46
View File
@@ -0,0 +1,46 @@
.footer {
margin-left: 16rem;
border-top: 1px solid rgb(229, 231, 235);
background-color: white;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem;
color: rgb(107, 114, 128);
}
.copyright {
font-size: 0.875rem;
color: rgb(107, 114, 128);
}
.links {
display: flex;
gap: 1.5rem;
}
.link {
color: rgb(107, 114, 128);
text-decoration: none;
transition: color 0.2s;
}
.link:hover {
color: rgb(55, 65, 81);
}
@media (max-width: 768px) {
.footer {
margin-left: 0;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.links {
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
}
}
+188
View File
@@ -0,0 +1,188 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.heroSection {
text-align: center;
margin-bottom: 2.5rem;
}
.title {
font-size: 2.5rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.75rem;
line-height: 1.05;
}
.subtitle {
font-size: 1.125rem;
color: rgb(75, 85, 99);
margin-bottom: 1rem;
line-height: 1.6;
}
.description {
color: rgb(107, 114, 128);
font-size: 1.125rem;
line-height: 1.7;
}
.cardsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
margin-bottom: 2.5rem;
}
.card {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
text-align: left;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.card:hover {
border-color: rgb(209, 213, 219);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.cardIconWrapper {
width: 3rem;
height: 3rem;
background-color: rgb(219, 234, 254);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.cardIcon {
font-size: 1.5rem;
color: rgb(37, 99, 235);
}
.cardTitle {
font-size: 1.125rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
}
.cardDescription {
color: rgb(107, 114, 128);
font-size: 0.875rem;
}
.recentSection {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.sectionIcon {
color: rgb(17, 24, 39);
}
.sectionTitle {
font-size: 1.25rem;
font-weight: 600;
color: rgb(17, 24, 39);
}
.documentsList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.documentItem {
padding: 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s;
}
.documentItem:hover {
background-color: rgb(249, 250, 251);
}
.documentItemNotLast {
border-bottom: 1px solid rgb(229, 231, 235);
}
.documentTitle {
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 0.5rem;
}
.documentMeta {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 1rem;
row-gap: 0.75rem;
font-size: 0.875rem;
color: rgb(75, 85, 99);
}
.metaLabel {
color: rgb(17, 24, 39);
font-weight: 500;
}
.documentButton {
margin-left: 0;
justify-self: end;
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.875rem;
font-weight: 500;
padding: 0.625rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.documentButton:hover {
background-color: rgb(29, 78, 216);
}
@media (max-width: 1024px) {
.cardsGrid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.cardsGrid {
grid-template-columns: 1fr;
}
.title {
font-size: 2rem;
}
.subtitle {
font-size: 1rem;
}
}
+215
View File
@@ -0,0 +1,215 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 80rem;
}
.header {
margin-bottom: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
margin-bottom: 0.75rem;
line-height: 1.1;
}
.subtitle {
color: rgb(75, 85, 99);
margin-bottom: 1.5rem;
font-size: 1.125rem;
line-height: 1.6;
}
.searchBarWrapper {
margin-bottom: 1.5rem;
}
.searchBar {
position: relative;
}
.searchInput {
width: 100%;
padding: 0.75rem 1.25rem;
color: rgb(55, 65, 81);
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
font-size: 1rem;
}
.searchInput::placeholder {
color: rgb(156, 163, 175);
}
.searchInput:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.searchIcon {
position: absolute;
right: 1.25rem;
top: 0.875rem;
color: rgb(156, 163, 175);
pointer-events: none;
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.filterHeader {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.filterIcon {
font-size: 1rem;
}
.filterControls {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.resultsInfo {
color: rgb(75, 85, 99);
margin-bottom: 1.25rem;
font-size: 0.875rem;
}
.resultsCount {
font-weight: 600;
}
.resultsQuery {
font-weight: 600;
}
.documentsList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.documentCard {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
transition: box-shadow 0.2s;
}
.documentCard:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.documentCardFlex {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.documentContent {
flex: 1;
}
.documentTitle {
font-size: 1.125rem;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 0.75rem;
}
.documentMeta {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
font-size: 0.875rem;
}
.metaItem {
display: flex;
flex-direction: column;
}
.metaLabel {
color: rgb(107, 114, 128);
}
.metaValue {
font-weight: 600;
color: rgb(17, 24, 39);
}
.openButton {
background-color: rgb(37, 99, 235);
color: white;
font-weight: 500;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.openButton:hover {
background-color: rgb(29, 78, 216);
}
.emptyState {
text-align: center;
padding: 3rem 0;
}
.emptyMessage {
color: rgb(107, 114, 128);
}
@media (max-width: 768px) {
.documentCardFlex {
flex-direction: column;
}
.documentMeta {
grid-template-columns: 1fr;
}
.openButton {
width: 100%;
}
}
+132
View File
@@ -0,0 +1,132 @@
.page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
background: #f9fafb;
}
.content {
flex: 1;
padding: 40px 40px 32px;
max-width: 960px;
}
.header {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 32px;
}
.titleRow {
display: flex;
align-items: center;
gap: 12px;
}
.titleIcon {
color: #1d4ed8;
flex-shrink: 0;
}
.title {
font-size: 2.5rem;
font-weight: 700;
color: #111827;
letter-spacing: -0.02em;
margin: 0;
line-height: 1.05;
}
.subtitle {
font-size: 1.125rem;
color: #6b7280;
margin: 0.75rem 0 1.5rem 0;
line-height: 1.6;
padding-left: 2px;
}
.sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 28px 32px;
}
.cardHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.cardIcon {
color: #1d4ed8;
flex-shrink: 0;
}
.cardTitle {
font-size: 17px;
font-weight: 700;
color: #111827;
margin: 0;
}
.fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.field {
font-size: 14px;
color: #374151;
line-height: 1.6;
}
.field strong {
font-weight: 600;
color: #111827;
}
.field a {
color: #1d4ed8;
text-decoration: none;
}
.field a:hover {
text-decoration: underline;
}
.prose {
display: flex;
flex-direction: column;
gap: 12px;
}
.prose p {
font-size: 14px;
color: #374151;
line-height: 1.7;
margin: 0;
}
.prose p a,
.prose p span.link {
color: #1d4ed8;
}
.highlight {
color: #1d4ed8;
}
.sectionContent {
margin-top: 16px;
}
+330
View File
@@ -0,0 +1,330 @@
.container {
padding: 2rem;
}
.maxWidth {
max-width: 96rem;
}
.header {
margin-bottom: 2rem;
}
.headerFlex {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.headerIcon {
color: rgb(37, 99, 235);
font-size: 1.75rem;
}
.title {
font-size: 2.25rem;
font-weight: 700;
color: rgb(17, 24, 39);
line-height: 1.1;
}
.subtitle {
color: rgb(75, 85, 99);
margin-bottom: 2rem;
font-size: 1.125rem;
line-height: 1.6;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.statCard {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.25rem;
}
.statLabel {
color: rgb(75, 85, 99);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.statValue {
font-size: 1.875rem;
font-weight: bold;
color: rgb(17, 24, 39);
}
.statIcon {
margin-top: 0.75rem;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.statIconBlue {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.statIconGreen {
background-color: rgb(220, 252, 231);
color: rgb(34, 197, 94);
}
.filtersBox {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.filterLabel {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgb(55, 65, 81);
font-weight: 500;
font-size: 0.875rem;
}
.filterIcon {
font-size: 1rem;
}
.select {
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
}
.select:focus {
outline: none;
box-shadow: 0 0 0 2px rgb(59, 130, 246);
}
.exportWrapper {
margin-left: auto;
}
.exportButton {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
font-size: 0.875rem;
padding: 0.375rem 1rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.exportButton:hover {
background-color: rgb(249, 250, 251);
}
.resultsInfo {
color: rgb(75, 85, 99);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.resultsCount {
font-weight: 600;
}
.table {
background-color: white;
border: 1px solid rgb(229, 231, 235);
border-radius: 0.75rem;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.tableHeader {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
background-color: rgb(249, 250, 251);
border-bottom: 1px solid rgb(229, 231, 235);
padding: 0.75rem 1.5rem;
}
.headerCell {
font-size: 0.875rem;
font-weight: 600;
color: rgb(55, 65, 81);
}
.tableRow {
display: grid;
grid-template-columns: 0.8fr minmax(180px,2fr) 1fr 1.2fr 1fr 0.8fr 0.6fr;
gap: 1rem;
padding: 1rem 1.5rem;
align-items: center;
}
.tableRow:hover {
background-color: rgb(249, 250, 251);
transition: background-color 0.2s;
}
.tableRowBorder {
border-bottom: 1px solid rgb(229, 231, 235);
}
.statusBadge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.statusBadgeNew {
background-color: rgb(220, 252, 231);
color: rgb(22, 163, 74);
}
.statusBadgeUpdated {
background-color: rgb(219, 234, 254);
color: rgb(37, 99, 235);
}
.titleCell {
font-weight: 500;
color: rgb(17, 24, 39);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell {
font-size: 0.875rem;
color: rgb(75, 85, 99);
}
.cellTruncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.viewButton {
background-color: rgb(37, 99, 235);
color: white;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.viewButton:hover {
background-color: rgb(29, 78, 216);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
.paginationButton {
padding: 0.5rem 1rem;
font-size: 0.875rem;
border: 1px solid rgb(209, 213, 219);
border-radius: 0.5rem;
color: rgb(55, 65, 81);
background-color: white;
cursor: pointer;
transition: all 0.2s;
}
.paginationButton:hover:not(:disabled) {
background-color: rgb(249, 250, 251);
}
.paginationButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageNumber {
width: 2.25rem;
height: 2.25rem;
font-size: 0.875rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
}
.pageNumberActive {
background-color: rgb(37, 99, 235);
color: white;
}
.pageNumberInactive {
border: 1px solid rgb(209, 213, 219);
color: rgb(55, 65, 81);
background-color: white;
}
.pageNumberInactive:hover {
background-color: rgb(249, 250, 251);
}
@media (max-width: 1024px) {
.statsGrid {
grid-template-columns: repeat(2, 1fr);
}
.tableHeader,
.tableRow {
grid-template-columns: 0.6fr 1.5fr 0.8fr 1fr 0.8fr 0.6fr 0.5fr;
}
}
@media (max-width: 768px) {
.statsGrid {
grid-template-columns: 1fr;
}
.tableHeader,
.tableRow {
grid-template-columns: 0.6fr 1.2fr 0.6fr 0.8fr 0.6fr 0.5fr 0.4fr;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.headerCell,
.cell {
font-size: 0.75rem;
}
}
+150
View File
@@ -0,0 +1,150 @@
.sidebar {
width: 16rem;
background-color: white;
border-right: 1px solid rgb(229, 231, 235);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
height: 100%;
z-index: 10;
}
.header {
padding: 1.5rem 1.25rem;
border-bottom: 1px solid rgb(229, 231, 235);
}
.title {
font-size: 1.5rem;
font-weight: 700;
color: rgb(17, 24, 39);
line-height: 1.2;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1rem;
color: rgb(75, 85, 99);
margin: 0;
line-height: 1.6;
}
.nav {
flex: 1;
padding: 1rem 0.75rem;
}
.navButton {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
transition: background-color 0.2s;
margin-bottom: 0.25rem;
border: none;
cursor: pointer;
background: white;
}
.navButtonActive {
background-color: rgb(243, 244, 246);
color: rgb(17, 24, 39);
}
.navButtonInactive {
color: rgb(55, 65, 81);
}
.navButtonInactive:hover {
background-color: rgb(249, 250, 251);
}
.navIconActive {
color: rgb(75, 85, 99);
}
.navIconInactive {
color: rgb(156, 163, 175);
}
/* ===== USER ===== */
.userSection {
display: flex;
justify-content: center;
padding: 0.75rem 0;
}
.userAvatar {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgb(249, 250, 251);
border: 1px solid rgb(229, 231, 235);
color: rgb(107, 114, 128);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.userAvatar:hover {
background: rgb(243, 244, 246);
border-color: rgb(209, 213, 219);
}
/* ===== LOGOUT ===== */
.logoutBtn {
display: flex;
align-items: center;
gap: 0.75rem;
width: calc(100% - 1.5rem);
margin: 0 0.75rem 1rem 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid rgb(229, 231, 235);
background: white;
color: rgb(107, 114, 128);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.logoutBtn:hover {
background: rgb(249, 250, 251);
border-color: rgb(209, 213, 219);
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
position: relative;
height: auto;
margin-bottom: 1rem;
}
}
+51
View File
@@ -0,0 +1,51 @@
body {
margin: 0;
font-family: "Segoe UI", sans-serif;
background: #f4f6f8;
}
.layout {
display: flex;
}
.sidebar {
width: 240px;
height: 100vh;
background: #1f2937;
color: white;
padding: 20px;
}
.sidebar h2 {
margin-bottom: 30px;
}
.sidebar a {
display: block;
color: #cbd5e1;
margin-bottom: 15px;
text-decoration: none;
}
.sidebar a:hover {
color: white;
}
.content {
flex: 1;
padding: 30px;
}
.cards {
display: flex;
gap: 20px;
margin-top: 20px;
}
.card {
background: white;
padding: 20px;
border-radius: 10px;
width: 200px;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
+7
View File
@@ -0,0 +1,7 @@
export interface Document {
id: number
title: string
ingredient: string | null
source: string
pdf: string | null
}