import sidebar

This commit is contained in:
el 2025-05-30 13:08:23 +02:00
parent 661f7bfdfa
commit e1409cb8ca
7 changed files with 1091 additions and 115 deletions

View file

@ -239,9 +239,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.15.26", "version": "22.15.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.26.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.27.tgz",
"integrity": "sha512-lgISkNrqdQ5DAzjBhnDNGKDuXDNo7/1V4FhNzsKREhWLZTOELQAptuAnJMzHtUl1qyEBBy9lNBKQ9WjyiSloTw==", "integrity": "sha512-5fF+eu5mwihV2BeVtX5vijhdaZOfkQTATrePEaXTcKqI16LhJ7gi2/Vhd9OZM0UojcdmiOCVg5rrax+i1MdoQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"axios": "^1.6.8", "axios": "^1.6.8",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",

View file

@ -1,18 +1,129 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; // Importe les composants de routage ```typescript
import JobSearch from './components/JobSearch'; // job/frontend/src/App.tsx
import JobDetail from './components/JobDetail'; // Importe le nouveau composant de détail import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import JobList from './components/JobList';
import JobDetail from './components/JobDetail';
// import Navbar from './components/Navbar'; // <-- SUPPRIMER CET IMPORT
import Sidebar from './components/Sidebar'; // Garde cet import
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import Box from '@mui/material/Box';
function App() { // Largeur de la sidebar
const drawerWidth = 240; // en pixels, comme dans Notion
// --- Gardez votre thème Material UI 'Notion-like' inchangé ici ---
const customTheme = createTheme({
palette: {
mode: 'light',
primary: { main: '#333333', light: '#555555', dark: '#1a1a1a', contrastText: '#FFFFFF' },
secondary: { main: '#6A5ACD', light: '#8C7FD9', dark: '#4C3B9B', contrastText: '#FFFFFF' },
background: { default: '#F7F7F7', paper: '#FFFFFF' },
text: { primary: '#212121', secondary: '#616161', disabled: '#BDBDBD' },
error: { main: '#EF9A9A' }, warning: { main: '#FFCC80' }, info: { main: '#90CAF9' }, success: { main: '#A5D6A7' },
},
typography: {
fontFamily: [
'Inter', 'Roboto', 'Arial', 'sans-serif', '"Apple Color Emoji"', '"Segoe UI Emoji"', '"Segoe UI Symbol"',
].join(','),
h1: { fontSize: '2.5rem', fontWeight: 600, color: '#212121' },
h2: { fontSize: '2rem', fontWeight: 600, color: '#212121' },
h3: { fontSize: '1.75rem', fontWeight: 600, color: '#212121' },
body1: { fontSize: '1rem', lineHeight: 1.6, color: '#212121' },
body2: { fontSize: '0.875rem', lineHeight: 1.5, color: '#616161' },
button: { textTransform: 'none' },
},
components: {
MuiAppBar: { // Optionnel : Si vous voulez garder une AppBar très minimale pour des actions spécifiques (sans navigation)
styleOverrides: {
colorPrimary: {
backgroundColor: '#FFFFFF', color: '#212121', boxShadow: '0px 1px 4px rgba(0, 0, 0, 0.04)',
},
},
},
MuiButton: {
styleOverrides: {
root: { borderRadius: '6px', textTransform: 'none' },
containedPrimary: {
backgroundColor: '#ECEFF1', color: '#212121', boxShadow: 'none',
'&:hover': { backgroundColor: '#CFD8DC', boxShadow: 'none' },
'&:active': { backgroundColor: '#B0BEC5' },
},
containedSecondary: {
backgroundColor: '#6A5ACD', color: '#FFFFFF', boxShadow: 'none',
'&:hover': { backgroundColor: '#4C3B9B', boxShadow: 'none' },
},
outlined: {
borderColor: '#CFD8DC', color: '#616161',
'&:hover': { borderColor: '#B0BEC5', backgroundColor: 'rgba(0, 0, 0, 0.02)' },
},
},
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: '8px', boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.05)', backgroundColor: '#FFFFFF',
},
},
},
MuiTextField: {
defaultProps: { variant: 'outlined' },
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '6px !important', backgroundColor: '#FFFFFF',
'&.Mui-focused fieldset': { borderColor: '#B0BEC5' },
},
'& .MuiInputLabel-root': { color: '#616161' },
},
},
},
MuiInputBase: {
styleOverrides: { input: { padding: '12px 14px' } },
},
MuiSelect: {
styleOverrides: { select: { borderRadius: '6px !important', padding: '12px 14px' } },
},
},
});
// --- Fin du thème Material UI 'Notion-like' ---
const App: React.FC = () => {
return ( return (
<Router> {/* Enveloppe toute l'application dans le routeur */} <ThemeProvider theme={customTheme}>
<div className="App"> <CssBaseline />
<Routes> {/* Définit les routes */} <Router>
<Route path="/" element={<JobSearch />} /> {/* Route pour la recherche d'offres */} <Box sx={{ display: 'flex', minHeight: '100vh', backgroundColor: 'background.default' }}> {/* Conteneur principal flex */}
<Route path="/jobs/:id" element={<JobDetail />} /> {/* Route pour le détail d'une offre */}
{/* Sidebar : elle prend sa largeur fixe */}
<Sidebar drawerWidth={drawerWidth} />
{/* Contenu principal : prend l'espace restant et a une marge à gauche */}
<Box
component="main"
sx={{
flexGrow: 1, // Prend l'espace restant
p: { xs: 2, md: 4 }, // Padding général pour le contenu (réactif)
width: { sm: `calc(100% - ${drawerWidth}px)` }, // Prend toute la largeur moins la sidebar sur desktop
ml: { sm: `${drawerWidth}px` }, // Marge à gauche égale à la largeur de la sidebar sur desktop
// Pas besoin de mt (margin-top) car il n'y a plus de Navbar en haut.
// S'assure que le contenu peut défiler indépendamment si trop long
overflowY: 'auto',
backgroundColor: 'background.default', // Couleur de fond du contenu
}}
>
{/* Ici seront rendues vos routes de contenu */}
<Routes>
<Route path="/" element={<JobList />} />
<Route path="/jobs/:id" element={<JobDetail />} />
</Routes> </Routes>
</div> </Box>
</Box>
</Router> </Router>
</ThemeProvider>
); );
} };
export default App; export default App;
```

View file

@ -1,9 +1,18 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link as RouterLink } from 'react-router-dom'; // Renommez Link pour éviter les conflits avec MUI Button
import axios from 'axios'; import axios from 'axios';
import {
Box, // Conteneur générique pour le style
Typography, // Titres, paragraphes
Button, // Bouton "Postuler"
CircularProgress, // Indicateur de chargement
Paper, // Pour encadrer le contenu de l'offre
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; // Icône pour le bouton retour
import type { JobOffer } from '../types'; // Use type-only import import type { JobOffer } from '../types'; // Use type-only import
const API_BASE_URL = 'http://localhost:3000/api/jobs'; const API_BASE_URL = 'http://localhost:3000/api/jobs'; // Assurez-vous que c'est la bonne URL de votre API backend
const JobDetail: React.FC = () => { const JobDetail: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -19,11 +28,11 @@ const JobDetail: React.FC = () => {
const response = await axios.get<JobOffer>(`${API_BASE_URL}/${id}`); const response = await axios.get<JobOffer>(`${API_BASE_URL}/${id}`);
setJob(response.data); setJob(response.data);
} catch (err) { } catch (err) {
console.error('Erreur lors de la récupération des détails de l\'offre:', err); console.error('Error fetching job detail:', err);
if (axios.isAxiosError(err) && err.response?.status === 404) { if (axios.isAxiosError(err) && err.response?.status === 404) {
setError('Offre d\'emploi non trouvée.'); setError('Job offer not found.');
} else { } else {
setError('Impossible de charger les détails de l\'offre. Veuillez réessayer.'); setError('Failed to load job offer details. Please try again.');
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -36,85 +45,128 @@ const JobDetail: React.FC = () => {
}, [id]); }, [id]);
const handleApplyClick = () => { const handleApplyClick = () => {
const applyUrl = job?.urlOffre; if (job?.urlOffre) {
window.open(job.urlOffre, '_blank'); // Ouvre l'URL dans un nouvel onglet
if (applyUrl) {
window.open(applyUrl, '_blank', 'noopener,noreferrer');
} else { } else {
alert("Aucune URL de candidature disponible pour cette offre."); // Utilisation d'un alert MUI ou d'un SnackBar serait mieux ici,
// mais pour l'instant, gardons l'alert JS pour la simplicité.
alert("Désolé, l'URL de candidature n'est pas disponible pour cette offre.");
} }
}; };
if (loading) { if (loading) {
return <p className="info-message">Chargement des détails de l'offre...</p>; return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '80vh' }}>
<CircularProgress />
<Typography variant="body1" sx={{ ml: 2, color: 'text.secondary' }}>
Chargement des détails de l'offre...
</Typography>
</Box>
);
} }
if (error) { if (error) {
return ( return (
<div className="job-detail-container"> <Box sx={{ textAlign: 'center', py: 4, maxWidth: '800px', mx: 'auto' }}>
<p className="info-message" style={{ color: 'red' }}>Erreur : {error}</p> <Typography variant="h6" color="error" sx={{ mb: 2 }}>Erreur : {error}</Typography>
<Link to="/" style={{ display: 'block', textAlign: 'center', marginTop: '20px' }}>Retour à la recherche</Link> <Button component={RouterLink} to="/" variant="outlined">
</div> Retour à la recherche
</Button>
</Box>
); );
} }
if (!job) { if (!job) {
return ( return (
<div className="job-detail-container"> <Box sx={{ textAlign: 'center', py: 4, maxWidth: '800px', mx: 'auto' }}>
<p className="info-message">Aucun détail d'offre disponible.</p> <Typography variant="h6" sx={{ mb: 2, color: 'text.secondary' }}>Aucun détail d'offre disponible.</Typography>
<Link to="/" style={{ display: 'block', textAlign: 'center', marginTop: '20px' }}>Retour à la recherche</Link> <Button component={RouterLink} to="/" variant="outlined">
</div> Retour à la recherche
</Button>
</Box>
); );
} }
return ( return (
<div className="job-detail-container" style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', backgroundColor: '#fff', borderRadius: '8px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}> <Box sx={{
<Link to="/" style={{ textDecoration: 'none', color: '#3498db', fontWeight: 'bold', marginBottom: '20px', display: 'inline-block' }}> padding: { xs: 2, md: 4 }, // Padding réactif
&larr; Retour à la recherche maxWidth: '800px',
</Link> margin: '20px auto',
<h1 style={{ color: '#2c3e50', marginBottom: '10px' }}>{job.title}</h1> backgroundColor: 'background.default', // Fond de la page (non du Paper)
<p style={{ fontSize: '1.1em', color: '#555', marginBottom: '15px' }}> }}>
<strong>Entreprise :</strong> {job.companyName || 'Non spécifié'} <Button
</p> component={RouterLink} // Utilisez RouterLink pour la navigation interne
<p style={{ fontSize: '1.1em', color: '#555', marginBottom: '15px' }}> to="/"
<strong>Localisation :</strong> {job.locationLabel || job.cityName || 'Non spécifié'} startIcon={<ArrowBackIcon />}
</p> variant="outlined" // Style de bouton 'outlined' comme dans Notion
<p style={{ fontSize: '1.1em', color: '#555', marginBottom: '15px' }}> sx={{ mb: 3 }} // Marge en bas
<strong>Type de contrat :</strong> {job.contractLabel || job.contractType || 'Non spécifié'} >
</p> Retour à la recherche d'offres
<p style={{ fontSize: '1.1em', color: '#555', marginBottom: '15px' }}> </Button>
<strong>Date de publication :</strong> {new Date(job.publicationDate).toLocaleDateString('fr-FR')}
</p>
{job.romeLabel && <p style={{ fontSize: '1.1em', color: '#555', marginBottom: '15px' }}><strong>Code ROME :</strong> {job.romeLabel}</p>}
{job.postalCode && <p style={{ fontSize: '1.1em', color: '#555', marginBottom: '15px' }}><strong>Code postal :</strong> {job.postalCode}</p>}
{job.departmentCode && <p style={{ fontSize: '1.1em', color: '#555', marginBottom: '15px' }}><strong>Département :</strong> {job.departmentCode}</p>}
<h2 style={{ color: '#2c3e50', marginTop: '30px', marginBottom: '10px' }}>Description</h2> <Paper elevation={0} sx={{ // Utilisation de Paper pour le bloc de contenu de l'offre
<p style={{ lineHeight: '1.6', whiteSpace: 'pre-wrap' }}>{job.description}</p> p: { xs: 3, md: 4 }, // Padding
borderRadius: '12px', // Bords arrondis
boxShadow: '0px 4px 10px rgba(0, 0, 0, 0.05)', // Ombre douce
backgroundColor: 'background.paper', // Fond blanc du Paper
}}>
<Typography variant="h4" component="h1" gutterBottom sx={{ color: 'text.primary' }}>
{job.title}
</Typography>
<Typography variant="body1" sx={{ mb: 1.5, color: 'text.secondary' }}>
<strong>Entreprise :</strong> {job.companyName || 'N/A'}
</Typography>
<Typography variant="body1" sx={{ mb: 1.5, color: 'text.secondary' }}>
<strong>Localisation :</strong> {job.locationLabel || job.cityName || 'N/A'}
</Typography>
<Typography variant="body1" sx={{ mb: 1.5, color: 'text.secondary' }}>
<strong>Contrat :</strong> {job.contractLabel || job.contractType || 'N/A'}
</Typography>
<Typography variant="body1" sx={{ mb: 1.5, color: 'text.secondary' }}>
<strong>Publiée le :</strong> {new Date(job.publicationDate).toLocaleDateString()}
</Typography>
{job.romeLabel && (
<Typography variant="body1" sx={{ mb: 1.5, color: 'text.secondary' }}>
<strong>ROME :</strong> {job.romeLabel}
</Typography>
)}
{job.postalCode && (
<Typography variant="body1" sx={{ mb: 1.5, color: 'text.secondary' }}>
<strong>Code postal :</strong> {job.postalCode}
</Typography>
)}
{job.departmentCode && (
<Typography variant="body1" sx={{ mb: 1.5, color: 'text.secondary' }}>
<strong>Département :</strong> {job.departmentCode}
</Typography>
)}
<Typography variant="h5" component="h2" sx={{ mt: 4, mb: 2, color: 'text.primary' }}>
Description
</Typography>
<Typography variant="body1" sx={{ lineHeight: 1.7, whiteSpace: 'pre-wrap', color: 'text.primary' }}>
{job.description}
</Typography>
{/* Bouton "Postuler" - s'affiche uniquement si job.urlOffre existe */}
{job.urlOffre && ( {job.urlOffre && (
<button <Box sx={{ mt: 4, textAlign: 'center' }}>
<Button
variant="contained" // Utilisez le style 'contained' du thème
color="secondary" // Utilisez la couleur secondaire pour le bouton "Postuler"
onClick={handleApplyClick} onClick={handleApplyClick}
style={{ sx={{
backgroundColor: '#28a745', // Vert pour le bouton "Postuler" px: 4, // Padding horizontal
color: 'white', py: 1.5, // Padding vertical
border: 'none', fontSize: '1.05em',
borderRadius: '5px', borderRadius: '8px', // Bords arrondis
padding: '12px 25px',
fontSize: '1.1em',
cursor: 'pointer',
marginTop: '30px',
display: 'block',
width: 'fit-content',
margin: '30px auto 0 auto', // Centre le bouton
transition: 'background-color 0.3s ease',
}} }}
> >
Postuler maintenant Postuler maintenant
</button> </Button>
</Box>
)} )}
</div> </Paper>
</Box>
); );
}; };

View file

@ -0,0 +1,33 @@
// job/frontend/src/components/Navbar.tsx
import React from 'react';
import { Link } from 'react-router-dom';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
const Navbar: React.FC = () => {
return (
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
<Link to="/" style={{ textDecoration: 'none', color: 'inherit' }}>
JobFinder
</Link>
</Typography>
<Button color="inherit" component={Link} to="/">
Rechercher
</Button>
{/* FUTURE: Ajoutez d'autres boutons ici pour Favoris, Connexion, etc. */}
{/* <Button color="inherit" component={Link} to="/favorites">
Mes Favoris
</Button> */}
{/* <Button color="inherit" component={Link} to="/login">
Connexion
</Button> */}
</Toolbar>
</AppBar>
);
};
export default Navbar;

View file

@ -0,0 +1,97 @@
// job/frontend/src/components/Sidebar.tsx
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {
Drawer, // Composant MUI pour la barre latérale
Toolbar, // Pour aligner le contenu après la Navbar
List, // Conteneur de liste
ListItem, // Élément de liste
ListItemButton, // Bouton cliquable pour l'élément de liste
ListItemIcon, // Pour les icônes à gauche du texte
ListItemText, // Pour le texte de l'élément
Typography, // Pour les titres ou textes
Divider, // Séparateur visuel
Box // Conteneur générique
} from '@mui/material';
// Importation des icônes
import SearchIcon from '@mui/icons-material/Search';
// FUTURES ICÔNES POUR LES FAVORIS, COMPTE, ETC.
// import StarIcon from '@mui/icons-material/Star';
// import AccountCircleIcon from '@mui/icons-material/AccountCircle';
// Propriétés attendues par le composant Sidebar
interface SidebarProps {
drawerWidth: number;
}
const Sidebar: React.FC<SidebarProps> = ({ drawerWidth }) => {
return (
<Drawer
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': { // Style du "papier" (fond) de la sidebar
width: drawerWidth,
boxSizing: 'border-box',
backgroundColor: 'background.paper', // Fond blanc du thème
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.05)', // Ombre douce
borderRadius: '0 8px 8px 0', // Bords arrondis seulement à droite
overflowX: 'hidden', // Empêche le débordement horizontal
},
}}
variant="permanent" // La sidebar est toujours visible
anchor="left" // Positionnée à gauche
>
{/* Un Toolbar pour s'aligner avec la Navbar en haut */}
{/* Peut être utilisé pour un logo ou un titre dans la sidebar si vous enlevez le titre de la Navbar */}
<Toolbar sx={{
backgroundColor: 'primary.main', // Assurez-vous que cette couleur correspond à la Navbar ou est transparente
minHeight: { xs: '56px', sm: '64px' }, // Hauteur de la Navbar
}}>
{/* Laissez vide ou ajoutez un logo/titre de l'application ici pour une disposition Notion-like */}
<Typography variant="h6" noWrap component="div" sx={{ color: 'primary.contrastText', display: 'flex', alignItems: 'center' }}>
<img src="/logo-jobfinder.png" alt="JobFinder Logo" style={{ height: '30px', marginRight: '10px' }} /> {/* Si vous avez un logo */}
JobFinder
</Typography>
</Toolbar>
<Divider /> {/* Ligne de séparation */}
<Box sx={{ overflow: 'auto' }}> {/* Permet le défilement si le contenu dépasse */}
<List>
{/* Lien vers la page de recherche */}
<ListItem disablePadding>
<ListItemButton component={RouterLink} to="/">
<ListItemIcon>
<SearchIcon sx={{ color: 'text.secondary' }} /> {/* Icône de recherche */}
</ListItemIcon>
<ListItemText primary="Rechercher" sx={{ color: 'text.primary' }} />
</ListItemButton>
</ListItem>
{/* FUTURS LIENS DE LA PHASE 3 */}
{/* <ListItem disablePadding>
<ListItemButton component={RouterLink} to="/favorites">
<ListItemIcon>
<StarIcon sx={{ color: 'text.secondary' }} />
</ListItemIcon>
<ListItemText primary="Mes Favoris" sx={{ color: 'text.primary' }} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={RouterLink} to="/account">
<ListItemIcon>
<AccountCircleIcon sx={{ color: 'text.secondary' }} />
</ListItemIcon>
<ListItemText primary="Mon Compte" sx={{ color: 'text.primary' }} />
</ListItemButton>
</ListItem> */}
</List>
<Divider />
{/* Vous pouvez ajouter d'autres sections de liens ici */}
</Box>
</Drawer>
);
};
export default Sidebar;