Initial release

This commit is contained in:
Francesco Carmelo Capria 2025-06-21 18:15:33 +02:00
commit ae5e4b8873
52 changed files with 17572 additions and 0 deletions

1
frontend/.env.production Normal file
View file

@ -0,0 +1 @@
REACT_APP_API_URL=/api

28
frontend/Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# Build stage
FROM node:18-alpine as build
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY . .
ENV GENERATE_SOURCEMAP=false
ENV ESLINT_NO_DEV_ERRORS=true
ENV DISABLE_ESLINT_PLUGIN=true
RUN pnpm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

30
frontend/nginx.conf Normal file
View file

@ -0,0 +1,30 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 300;
proxy_send_timeout 300;
proxy_read_timeout 300;
send_timeout 300;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

54
frontend/package.json Normal file
View file

@ -0,0 +1,54 @@
{
"name": "scientify-frontend",
"version": "1.0.0",
"description": "Scientify frontend - modern platform for managing and sharing scientific publications",
"author": "Francesco Carmelo Capria",
"homepage": ".",
"private": true,
"keywords": [
"scientif publications",
"research"
],
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@tanstack/react-query": "^5.76.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"jwt-decode": "^4.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"analyze": "npm run build && npx serve -s build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

12629
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Sfondo circolare -->
<circle cx="32" cy="32" r="30" fill="url(#gradient)" stroke="#ffffff" stroke-width="2"/>
<!-- Microscopio base -->
<rect x="26" y="48" width="12" height="8" fill="#ffffff" rx="2"/>
<!-- Corpo del microscopio -->
<rect x="28" y="35" width="8" height="15" fill="#ffffff" rx="1"/>
<!-- Oculare -->
<circle cx="32" cy="20" r="4" fill="#ffffff"/>
<circle cx="32" cy="20" r="2.5" fill="#667eea"/>
<!-- Braccio laterale -->
<rect x="36" y="28" width="12" height="3" fill="#ffffff" rx="1.5"/>
<circle cx="48" cy="29.5" r="3" fill="#ffffff"/>
<circle cx="48" cy="29.5" r="1.5" fill="#667eea"/>
<!-- Obiettivo -->
<rect x="30" y="40" width="4" height="6" fill="#667eea" rx="1"/>
<!-- Piattaforma campione -->
<rect x="24" y="42" width="16" height="2" fill="#ffffff" rx="1"/>
<!-- Dettagli decorativi -->
<circle cx="20" cy="15" r="1.5" fill="#ffffff" opacity="0.8"/>
<circle cx="44" cy="18" r="1" fill="#ffffff" opacity="0.6"/>
<circle cx="18" cy="35" r="1" fill="#ffffff" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#667eea" />
<meta
name="description"
content="Scientify - modern platform for managing and sharing scientific publications"
/>
<meta name="keywords" content="" />
<meta name="author" content="Scientify Team" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Scientify - Scientific Publications Management" />
<meta property="og:description" content="modern platform for managing and sharing scientific publications" />
<meta property="og:image" content="%PUBLIC_URL%/favicon.svg" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="Scientify - Scientific Publications Management" />
<meta property="twitter:description" content="modern platform for managing and sharing scientific publications" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.svg" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Scientify - Scientific Publications Management</title>
</head>
<body>
<noscript>Allow javascript to use Scientify.</noscript>
<div id="root"></div>
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,22 @@
{
"short_name": "Scientify",
"name": "Scientify - Scientific Publications Management",
"description": "Modern platform for managing and sharing scientific publications",
"icons": [
{
"src": "favicon.svg",
"type": "image/svg+xml",
"sizes": "any"
},
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#667eea",
"background_color": "#f8fafc",
"categories": ["education", "productivity", "utilities"]
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

30
frontend/src/App.js Normal file
View file

@ -0,0 +1,30 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { CustomThemeProvider } from "./context/ThemeContext";
import AppBarHeader from "./components/AppBarHeader";
import HomePage from "./pages/HomePage";
import UserPublicationsPage from "./pages/UserPublicationsPage";
import { useState } from "react";
import { Box } from "@mui/material";
function App() {
const [uploadOpen, setUploadOpen] = useState(false);
return (
<CustomThemeProvider>
<AuthProvider>
<BrowserRouter>
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
<AppBarHeader onUploadClick={() => setUploadOpen(true)} />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/me" element={<UserPublicationsPage />} />
</Routes>
</Box>
</BrowserRouter>
</AuthProvider>
</CustomThemeProvider>
);
}
export default App;

67
frontend/src/api/auth.js Normal file
View file

@ -0,0 +1,67 @@
import { API_URL } from "./client";
export async function login({ email, password }) {
const res = await fetch(`${API_URL}/auth/jwt/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: email, password }),
});
if (!res.ok) throw new Error("Credenziali non valide");
const data = await res.json();
localStorage.setItem("access_token", data.access_token);
return data.access_token;
}
export async function register({ email, password, first_name, last_name }) {
const payload = {
email,
password,
is_active: true,
is_superuser: false,
is_verified: false
};
// Aggiungi nome e cognome solo se forniti
if (first_name) payload.first_name = first_name;
if (last_name) payload.last_name = last_name;
const res = await fetch(`${API_URL}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorData = await res.json();
if (res.status === 400) {
throw new Error("Email già registrata o password non valida");
}
throw new Error(errorData.detail || "Errore durante la registrazione");
}
const data = await res.json();
return data;
}
export async function logout() {
localStorage.removeItem("access_token");
return true;
}
// Funzione per aggiornare il profilo utente
export async function updateUserProfile({ first_name, last_name, token }) {
const res = await fetch(`${API_URL}/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({ first_name, last_name }),
});
if (!res.ok) {
throw new Error("Errore nell'aggiornamento del profilo");
}
return res.json();
}

View file

@ -0,0 +1,44 @@
export const API_URL = process.env.REACT_APP_API_URL || "http://localhost:8000";
export function getAuthHeaders(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
// Configurazione centralizzata per le chiamate API
export const apiConfig = {
baseURL: API_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
}
};
// Helper per gestire errori API
export function handleApiError(error) {
if (error.response) {
// Server ha risposto con codice di errore
const status = error.response.status;
const message = error.response.data?.detail || error.response.data?.message || 'Errore del server';
switch (status) {
case 401:
localStorage.removeItem('access_token');
window.location.href = '/';
throw new Error('Sessione scaduta. Effettua nuovamente il login.');
case 403:
throw new Error('Non hai i permessi per questa operazione.');
case 404:
throw new Error('Risorsa non trovata.');
case 422:
throw new Error('Dati non validi: ' + message);
default:
throw new Error(message);
}
} else if (error.request) {
// Nessuna risposta dal server
throw new Error('Impossibile contattare il server. Verifica la connessione.');
} else {
// Errore nella configurazione della richiesta
throw new Error('Errore di configurazione: ' + error.message);
}
}

View file

@ -0,0 +1,5 @@
import config from '../config';
export const API_URL = config.API_URL;
// Il resto del file rimane invariato

View file

@ -0,0 +1,54 @@
import { API_URL, getAuthHeaders } from "./client";
export async function fetchPublications({ queryKey }) {
const [_key, { search, orderBy, token }] = queryKey;
let url = `${API_URL}/publications?`;
if (search) url += `search=${encodeURIComponent(search)}&`;
if (orderBy) url += `order_by=${orderBy}&`;
const res = await fetch(url, {
headers: getAuthHeaders(token),
});
if (!res.ok) throw new Error("Errore nel caricamento pubblicazioni");
return res.json();
}
export async function uploadPublication({ file, token }) {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${API_URL}/publications`, {
method: "POST",
headers: getAuthHeaders(token),
body: formData,
});
if (!res.ok) throw new Error("Errore durante l'upload");
return res.json();
}
export async function fetchUserPublications({ queryKey }) {
const [_key, { token, orderBy }] = queryKey;
let url = `${API_URL}/users/me/publications`;
if (orderBy) url += `?order_by=${orderBy}`;
const res = await fetch(url, {
headers: getAuthHeaders(token),
});
if (!res.ok) throw new Error("Errore nel caricamento delle tue pubblicazioni");
return res.json();
}
// NUOVA: Funzione per eliminare una pubblicazione
export async function deletePublication({ publicationId, token }) {
const res = await fetch(`${API_URL}/publications/${publicationId}`, {
method: "DELETE",
headers: getAuthHeaders(token),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.detail || "Errore durante l'eliminazione della pubblicazione");
}
return res.json();
}

View file

@ -0,0 +1,132 @@
import {
AppBar,
Toolbar,
Button,
Typography,
Box,
Container,
IconButton,
Tooltip
} from "@mui/material";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import UserMenu from "./UserMenu";
import UploadModal from "./UploadModal";
import { useTheme } from "../context/ThemeContext";
import LightModeIcon from '@mui/icons-material/LightMode';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
export default function Header({ onUploadSuccess }) {
const [openUpload, setOpenUpload] = useState(false);
const { darkMode, toggleDarkMode } = useTheme();
const navigate = useNavigate();
const handleUploadSuccess = () => {
if (onUploadSuccess) {
onUploadSuccess();
}
};
return (
<>
<Box sx={{ mb: 3 }}>
<Container maxWidth="lg" sx={{ pt: 2 }}>
<AppBar
position="static"
sx={{
borderRadius: 4,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
background: darkMode
? 'linear-gradient(135deg, rgba(99, 102, 241, 0.9) 0%, rgba(139, 92, 246, 0.9) 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
backdropFilter: 'blur(20px)',
border: darkMode ? '1px solid rgba(255,255,255,0.1)' : 'none'
}}
>
<Toolbar sx={{ justifyContent: "space-between", py: 1 }}>
{/* Clickable logo */}
<Button
onClick={() => navigate('/')}
sx={{
textTransform: 'none',
padding: 0,
minWidth: 'auto',
'&:hover': {
backgroundColor: 'transparent',
transform: 'scale(1.05)',
},
transition: 'all 0.3s ease'
}}
>
<Typography
variant="h5"
sx={{
fontWeight: 'bold',
letterSpacing: '0.5px',
background: 'linear-gradient(45deg, #ffffff 30%, #f0f0f0 90%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
cursor: 'pointer'
}}
>
🔬 Scientify
</Typography>
</Button>
<Box display="flex" alignItems="center" gap={1}>
{/* Toggle Dark Mode */}
<Tooltip title={darkMode ? "Light mode" : "Dark mode"}>
<IconButton
onClick={toggleDarkMode}
sx={{
color: 'white',
backgroundColor: 'rgba(255,255,255,0.1)',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.2)',
}
}}
>
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
</Tooltip>
{/* Upload Button */}
<Button
variant="contained"
startIcon={<CloudUploadIcon />}
onClick={() => setOpenUpload(true)}
sx={{
ml: 1,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.15)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.25)',
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(0,0,0,0.3)'
},
fontWeight: 'bold',
textTransform: 'none',
transition: 'all 0.3s ease'
}}
>
Upload
</Button>
<UserMenu />
</Box>
</Toolbar>
</AppBar>
</Container>
</Box>
<UploadModal
open={openUpload}
onClose={() => setOpenUpload(false)}
onUploadSuccess={handleUploadSuccess}
/>
</>
);
}

View file

@ -0,0 +1,130 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Alert,
Box,
IconButton
} from "@mui/material";
import { useState } from "react";
import DeleteIcon from '@mui/icons-material/Delete';
import WarningIcon from '@mui/icons-material/Warning';
import CloseIcon from '@mui/icons-material/Close';
export default function DeletePublicationDialog({
open,
onClose,
publication,
onConfirmDelete,
loading = false
}) {
if (!publication) return null;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
}
}}
>
<DialogTitle sx={{
pb: 1,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'error.50'
}}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
<WarningIcon color="error" />
<Typography variant="h6" fontWeight="bold" color="error.main">
Confirm Deletion
</Typography>
</Box>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent sx={{ py: 3 }}>
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
<Typography variant="body2" fontWeight="medium">
Warning: this action cannot be undone!
</Typography>
</Alert>
<Typography variant="body1" sx={{ mb: 2 }}>
Are you sure you want to permanently delete this publication?
</Typography>
<Box
sx={{
p: 2,
bgcolor: 'grey.50',
borderRadius: 2,
border: '1px solid',
borderColor: 'grey.200'
}}
>
<Typography variant="subtitle2" fontWeight="bold" color="primary.main">
{publication.title}
</Typography>
{publication.authors && publication.authors.length > 0 && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Authors: {publication.authors.map(a => a.name).join(", ")}
</Typography>
)}
{publication.journal && (
<Typography variant="body2" color="text.secondary">
Published in: {publication.journal}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
Uploaded on: {new Date(publication.upload_date).toLocaleDateString()}
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, gap: 2 }}>
<Button
onClick={onClose}
variant="outlined"
disabled={loading}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 100
}}
>
Cancel
</Button>
<Button
onClick={() => onConfirmDelete(publication.id)}
variant="contained"
color="error"
disabled={loading}
startIcon={<DeleteIcon />}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 120
}}
>
{loading ? "Deleting..." : "Delete Permanently"}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,370 @@
import { useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Alert,
Box,
Typography,
IconButton,
Stack,
Divider,
Tabs,
Tab,
Chip
} from "@mui/material";
import { login, register } from "../api/auth";
import { useAuth } from "../context/AuthContext";
import CloseIcon from '@mui/icons-material/Close';
import LoginIcon from '@mui/icons-material/Login';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import PersonIcon from '@mui/icons-material/Person';
import LockIcon from '@mui/icons-material/Lock';
import EmailIcon from '@mui/icons-material/Email';
import { InputAdornment } from "@mui/material";
export default function LoginDialog({ open, onClose }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false);
const [tabValue, setTabValue] = useState(0); // 0 = Login, 1 = Register
const { setToken } = useAuth();
const isLogin = tabValue === 0;
const isRegister = tabValue === 1;
async function handleLogin() {
setError("");
setSuccess("");
setLoading(true);
try {
const token = await login({ email, password });
setToken(token);
setSuccess("Login successful!");
setTimeout(() => {
resetForm();
onClose();
}, 1000);
} catch (err) {
setError(err.message || "Login failed. Check your email and password.");
} finally {
setLoading(false);
}
}
async function handleRegister() {
setError("");
setSuccess("");
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setLoading(true);
try {
await register({
email,
password,
first_name: firstName,
last_name: lastName
});
setSuccess("🎉 Registration complete! You can now log in.");
setTimeout(() => {
setTabValue(0);
setPassword("");
setConfirmPassword("");
setFirstName("");
setLastName("");
}, 2000);
} catch (err) {
setError(err.message || "Error during registration");
} finally {
setLoading(false);
}
}
const resetForm = () => {
setEmail("");
setPassword("");
setConfirmPassword("");
setFirstName("");
setLastName("");
setError("");
setSuccess("");
setTabValue(0);
};
const handleClose = () => {
resetForm();
onClose();
};
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
setError("");
setSuccess("");
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 4,
overflow: 'hidden'
}
}}
>
<DialogTitle sx={{
pb: 1,
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
borderBottom: '1px solid',
borderColor: 'divider'
}}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
{isLogin ? <LoginIcon color="primary" /> : <PersonAddIcon color="primary" />}
<Typography variant="h6" fontWeight="bold">
Welcome to Scientify
</Typography>
</Box>
<IconButton onClick={handleClose} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab
label="Login"
icon={<LoginIcon />}
iconPosition="start"
sx={{ textTransform: 'none', fontWeight: 'bold' }}
/>
<Tab
label="Register"
icon={<PersonAddIcon />}
iconPosition="start"
sx={{ textTransform: 'none', fontWeight: 'bold' }}
/>
</Tabs>
</Box>
<DialogContent sx={{ py: 3 }}>
<Stack spacing={3}>
<Box textAlign="center">
{isLogin ? (
<Typography variant="body2" color="text.secondary">
Enter your credentials to access your account
</Typography>
) : (
<Stack spacing={1} alignItems="center">
<Typography variant="body2" color="text.secondary">
Create a new account to upload your publications
</Typography>
<Chip
label="Free • Fast • Secure"
size="small"
color="primary"
variant="outlined"
/>
</Stack>
)}
</Box>
{/* First and Last Name (only for registration) */}
{isRegister && (
<Box display="flex" gap={2}>
<TextField
label="First Name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
fullWidth
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<PersonIcon color="action" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
<TextField
label="Last Name"
value={lastName}
onChange={e => setLastName(e.target.value)}
fullWidth
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<PersonIcon color="action" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
</Box>
)}
<TextField
label="Email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
fullWidth
variant="outlined"
required
InputProps={{
startAdornment: (
<InputAdornment position="start">
<EmailIcon color="action" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
<TextField
label="Password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
fullWidth
variant="outlined"
required
helperText={isRegister ? "Minimum 6 characters" : ""}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LockIcon color="action" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
{isRegister && (
<TextField
label="Confirm Password"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
fullWidth
variant="outlined"
required
error={confirmPassword && password !== confirmPassword}
helperText={confirmPassword && password !== confirmPassword ? "Passwords do not match" : ""}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LockIcon color="action" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
)}
{error && (
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ borderRadius: 2 }}>
{success}
</Alert>
)}
</Stack>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 3, gap: 2 }}>
<Button
onClick={handleClose}
variant="outlined"
size="large"
sx={{ borderRadius: 2, textTransform: 'none', minWidth: 100 }}
>
Cancel
</Button>
{isLogin ? (
<Button
onClick={handleLogin}
variant="contained"
disabled={loading || !email || !password}
size="large"
startIcon={<LoginIcon />}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 120,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
{loading ? "Logging in..." : "Login"}
</Button>
) : (
<Button
onClick={handleRegister}
variant="contained"
disabled={loading || !email || !password || !confirmPassword || password !== confirmPassword}
size="large"
startIcon={<PersonAddIcon />}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 120,
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
}}
>
{loading ? "Registering..." : "Register"}
</Button>
)}
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,379 @@
import { useQuery } from "@tanstack/react-query";
import { fetchPublications } from "../api/publications";
import {
Card,
CardContent,
Typography,
Chip,
Box,
CircularProgress,
Alert,
Container,
Grid,
Button,
Divider,
Stack,
ButtonGroup
} from "@mui/material";
import { useAuth } from "../context/AuthContext";
import { useState } from "react";
import PersonIcon from '@mui/icons-material/Person';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import SortIcon from '@mui/icons-material/Sort';
import SortByAlphaIcon from '@mui/icons-material/SortByAlpha';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import LinkIcon from '@mui/icons-material/Link';
import config from '../config';
export default function PublicationList({ search }) {
const [orderBy, setOrderBy] = useState("date_desc");
const { token } = useAuth();
const { data, isLoading, error } = useQuery({
queryKey: ["publications", { search, orderBy, token }],
queryFn: fetchPublications,
});
// Function to get current sort information
const getOrderInfo = () => {
switch(orderBy) {
case "date_asc": return { type: "date", direction: "asc", label: "Date ascending", icon: <ArrowUpwardIcon /> };
case "date_desc": return { type: "date", direction: "desc", label: "Date descending", icon: <ArrowDownwardIcon /> };
case "title_asc": return { type: "title", direction: "asc", label: "Title A-Z", icon: <ArrowUpwardIcon /> };
case "title_desc": return { type: "title", direction: "desc", label: "Title Z-A", icon: <ArrowDownwardIcon /> };
default: return { type: "date", direction: "desc", label: "Date descending", icon: <ArrowDownwardIcon /> };
}
};
// Function to handle sort button clicks
const handleSort = (type) => {
const currentOrder = getOrderInfo();
if (currentOrder.type === type) {
// If same type, toggle direction
const newDirection = currentOrder.direction === "asc" ? "desc" : "asc";
setOrderBy(`${type}_${newDirection}`);
} else {
// If different type, set default direction
const defaultDirection = type === "date" ? "desc" : "asc";
setOrderBy(`${type}_${defaultDirection}`);
}
};
if (isLoading) {
return (
<Container maxWidth="lg">
<Box display="flex" justifyContent="center" mt={4}>
<CircularProgress size={60} />
</Box>
</Container>
);
}
if (error) {
return (
<Container maxWidth="lg">
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
{error.message}
</Alert>
</Container>
);
}
const currentOrder = getOrderInfo();
return (
<Container maxWidth="lg">
{/* Header with advanced sort controls */}
<Box sx={{ mb: 3 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Typography variant="h6" color="text.secondary">
{data?.length || 0} publications found
{search && (
<Typography component="span" variant="body2" color="primary.main" sx={{ ml: 1, fontWeight: 'bold' }}>
for "{search}"
</Typography>
)}
</Typography>
{/* Sort controls */}
{data?.length > 1 && (
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
<Box display="flex" alignItems="center" gap={1}>
<SortIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
Sort by:
</Typography>
</Box>
<ButtonGroup variant="outlined" size="small">
{/* Date button */}
<Button
variant={currentOrder.type === "date" ? "contained" : "outlined"}
onClick={() => handleSort("date")}
startIcon={currentOrder.type === "date" ? currentOrder.icon : <CalendarTodayIcon />}
sx={{
borderRadius: '8px 0 0 8px',
textTransform: 'none',
fontWeight: currentOrder.type === "date" ? 'bold' : 'normal',
minWidth: '100px'
}}
>
{currentOrder.type === "date" ? currentOrder.label : "Date"}
</Button>
{/* Title button */}
<Button
variant={currentOrder.type === "title" ? "contained" : "outlined"}
onClick={() => handleSort("title")}
startIcon={currentOrder.type === "title" ? currentOrder.icon : <SortByAlphaIcon />}
sx={{
borderRadius: '0 8px 8px 0',
textTransform: 'none',
fontWeight: currentOrder.type === "title" ? 'bold' : 'normal',
minWidth: '100px'
}}
>
{currentOrder.type === "title" ? currentOrder.label : "Title"}
</Button>
</ButtonGroup>
</Box>
)}
</Box>
{/* Active sort indicator */}
<Box sx={{ mt: 1 }}>
<Chip
label={`Active sort: ${currentOrder.label}`}
size="small"
color="primary"
variant="outlined"
icon={currentOrder.icon}
sx={{
fontWeight: 'medium',
'& .MuiChip-icon': {
fontSize: '1rem'
}
}}
/>
</Box>
{/* Info about keyword system */}
{search && (
<Alert severity="info" sx={{ mt: 2, borderRadius: 2 }}>
<Typography variant="body2">
🎯 <strong>Keyword-Based Search:</strong> Search prioritizes keywords, then authors, then titles.
Try combining keywords for more precise results!
</Typography>
</Alert>
)}
</Box>
{/* Publications grid */}
<Grid container spacing={3}>
{data?.map(pub => (
<Grid item xs={12} key={pub.id}>
<Card
elevation={3}
sx={{
borderRadius: 3,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 12px 40px rgba(0,0,0,0.12)'
}
}}
>
<CardContent sx={{ p: 3 }}>
{/* Title */}
<Typography
variant="h6"
component="h2"
sx={{
mb: 2,
fontWeight: 'bold',
color: 'primary.main',
lineHeight: 1.3
}}
>
{pub.title}
</Typography>
{/* Main information */}
<Stack direction="row" spacing={3} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
{/* Date */}
<Box display="flex" alignItems="center" gap={1}>
<CalendarTodayIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{new Date(pub.upload_date).toLocaleDateString()}
{pub.year && ` (${pub.year})`}
</Typography>
</Box>
{/* Authors */}
{pub.authors && pub.authors.length > 0 && (
<Box display="flex" alignItems="center" gap={1}>
<PersonIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{Array.isArray(pub.authors)
? pub.authors.map(a => a.name ?? a).join(", ")
: pub.authors}
</Typography>
</Box>
)}
</Stack>
{/* Journal/Conference */}
{pub.journal && (
<Box sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
<MenuBookIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary" fontWeight="medium">
Published in:
</Typography>
</Box>
<Chip
label={pub.journal}
variant="outlined"
sx={{
ml: 3,
backgroundColor: 'info.50',
borderColor: 'info.200',
color: 'info.700',
fontWeight: 'medium',
'&:hover': {
backgroundColor: 'info.100',
}
}}
/>
</Box>
)}
{/* Keywords */}
{pub.keywords && pub.keywords.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
<LocalOfferIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary" fontWeight="medium">
Keywords:
</Typography>
</Box>
<Box display="flex" flexWrap="wrap" gap={1} sx={{ ml: 3 }}>
{(Array.isArray(pub.keywords) ? pub.keywords : []).map((keyword, index) => (
<Chip
key={index}
label={keyword.name ?? keyword}
size="small"
sx={{
backgroundColor: 'primary.50',
color: 'primary.700',
fontWeight: 'medium',
'&:hover': {
backgroundColor: 'primary.100',
}
}}
/>
))}
</Box>
</Box>
)}
{/* DOI */}
{pub.doi && (
<Box sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
<LinkIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary" fontWeight="medium">
DOI:
</Typography>
</Box>
<Chip
label={pub.doi}
variant="outlined"
component="a"
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
clickable
sx={{
ml: 3,
backgroundColor: 'success.50',
borderColor: 'success.200',
color: 'success.700',
fontWeight: 'medium',
textDecoration: 'none',
'&:hover': {
backgroundColor: 'success.100',
textDecoration: 'none',
}
}}
/>
</Box>
)}
{/* Alert if no keywords */}
{(!pub.keywords || pub.keywords.length === 0) && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
This publication has no keywords! The system relies on keywords for search.
</Typography>
</Alert>
)}
<Divider sx={{ my: 2 }} />
{/* Actions */}
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"
startIcon={<PictureAsPdfIcon />}
href={`${config.API_URL}/download/${pub.id}`}
target="_blank"
rel="noopener noreferrer"
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold'
}}
>
View PDF
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Message for no results */}
{data?.length === 0 && (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
sx={{ py: 8 }}
>
<Typography variant="h6" color="text.secondary" gutterBottom>
No publications found
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Try modifying your search terms
</Typography>
{search && (
<Alert severity="info" sx={{ mt: 2, maxWidth: 500 }}>
<Typography variant="body2">
💡 <strong>Tip:</strong> The system primarily searches in keywords.
Try more generic terms or different combinations!
</Typography>
</Alert>
)}
</Box>
)}
</Container>
);
}

View file

@ -0,0 +1,51 @@
import { TextField, Box, Container, Paper } from "@mui/material";
import SearchIcon from '@mui/icons-material/Search';
import { InputAdornment } from "@mui/material";
import config from '../config';
export default function SearchBar({ value, onChange }) {
return (
<Container maxWidth="lg" sx={{ mb: 4 }}>
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Paper
elevation={3}
sx={{
borderRadius: 4,
overflow: 'hidden',
maxWidth: 600,
width: '100%'
}}
>
<TextField
value={value}
onChange={e => onChange(e.target.value)}
placeholder="Search by keywords, authors or title..."
variant="outlined"
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
sx: {
'& .MuiOutlinedInput-notchedOutline': {
border: 'none'
},
'&:hover .MuiOutlinedInput-notchedOutline': {
border: 'none'
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
border: 'none'
},
padding: '8px 16px'
}
}}
inputProps={{ "aria-label": "search" }}
/>
</Paper>
</Box>
</Container>
);
}

View file

@ -0,0 +1,567 @@
import React, { useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Box,
IconButton,
Typography,
Stack,
Button,
Alert,
Divider,
LinearProgress,
Paper,
Grid
} from "@mui/material";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CloseIcon from "@mui/icons-material/Close";
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import DescriptionIcon from '@mui/icons-material/Description';
import PublishIcon from '@mui/icons-material/Publish';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import LockIcon from '@mui/icons-material/Lock';
import LoginIcon from '@mui/icons-material/Login';
import InfoIcon from '@mui/icons-material/Info';
import { useAuth } from "../context/AuthContext";
import config from '../config';
export default function UploadModal({ open, onClose, onUploadSuccess }) {
const { token } = useAuth();
const [form, setForm] = useState({
title: "",
authors: "",
year: "",
journal: "",
doi: "", // ADDED DOI FIELD
});
const [file, setFile] = useState(null);
const [bibtex, setBibtex] = useState(null);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleFileChange = (e) => setFile(e.target.files[0]);
const handleBibtexChange = (e) => setBibtex(e.target.files[0]);
// 🎯 NEW LOGIC: Determine if fields are required
const fieldsRequired = !bibtex; // If no bibtex, fields are required
const isFormValid = fieldsRequired
? (form.title && form.authors && form.year && form.journal) // Case 1: all fields required
: true; // Case 2: with bibtex, fields not required
const handleSubmit = async (e) => {
e.preventDefault();
if (!token) {
setResult({ error: "You must be logged in to upload publications" });
return;
}
// 🎯 CUSTOM VALIDATION
if (!file) {
setResult({ error: "You must select a document to upload" });
return;
}
if (fieldsRequired && !isFormValid) {
setResult({ error: "Fill in all required fields or upload a BibTeX file" });
return;
}
setLoading(true);
setResult(null);
const formData = new FormData();
formData.append("file", file);
if (bibtex) formData.append("bibtex", bibtex);
// 🎯 CONDITIONAL FIELD SENDING: only if filled or required
if (form.title || fieldsRequired) formData.append("title", form.title);
if (form.authors || fieldsRequired) formData.append("authors", form.authors);
if (form.year || fieldsRequired) formData.append("year", form.year);
if (form.journal || fieldsRequired) formData.append("journal", form.journal);
if (form.doi) formData.append("doi", form.doi); // ADDED DOI
try {
const uploadUrl = `${config.API_URL}/upload/`;
const res = await fetch(uploadUrl, {
method: "POST",
body: formData,
headers: {
Authorization: `Bearer ${token}`,
},
});
if (res.status === 401) {
setResult({ error: "Unauthorized. Please login to upload publications." });
return;
}
if (!res.ok) {
const errorData = await res.json();
setResult({ error: errorData.detail || "Error during upload" });
return;
}
const data = await res.json();
setResult(data);
// Auto-close and reload after success
setTimeout(() => {
resetForm();
onClose();
if (onUploadSuccess) {
onUploadSuccess();
}
window.location.reload();
}, 2000);
} catch (err) {
setResult({ error: "Connection error during upload" });
} finally {
setLoading(false);
}
};
const resetForm = () => {
setForm({ title: "", authors: "", year: "", journal: "", doi: "" }); // ADDED DOI
setFile(null);
setBibtex(null);
setResult(null);
};
const handleClose = () => {
resetForm();
onClose();
};
// If user is not logged in, show warning message
const isDisabled = !token;
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 4,
overflow: 'hidden',
// 🔧 FIX: Limit maximum height and enable scroll
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column'
}
}}
>
<DialogTitle sx={{
pb: 1.5, // 🔧 FIX: Reduced padding
background: isDisabled
? 'linear-gradient(135deg, rgba(245, 101, 101, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)'
: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
borderBottom: '1px solid',
borderColor: 'divider',
// 🔧 FIX: Prevent title from resizing
flexShrink: 0
}}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
{isDisabled ? <LockIcon color="error" fontSize="small" /> : <CloudUploadIcon color="primary" fontSize="small" />}
<Typography variant="h6" fontWeight="bold" fontSize="1.1rem">
Upload a new publication
</Typography>
</Box>
<IconButton onClick={handleClose} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</DialogTitle>
{loading && <LinearProgress sx={{ flexShrink: 0 }} />}
<form onSubmit={handleSubmit} encType="multipart/form-data" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 🔧 FIX: DialogContent with scroll enabled */}
<DialogContent sx={{
py: 2, // 🔧 FIX: Reduced vertical padding
flex: 1,
overflow: 'auto', // 🔧 FIX: Enable automatic scroll
'&::-webkit-scrollbar': {
width: '8px'
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '4px'
},
'&::-webkit-scrollbar-thumb': {
background: '#c1c1c1',
borderRadius: '4px',
'&:hover': {
background: '#a1a1a1'
}
}
}}>
<Stack spacing={2}> {/* 🔧 FIX: Reduced spacing from 3 to 2 */}
{/* Warning for users not logged in */}
{isDisabled && (
<Alert
severity="warning"
icon={<LockIcon fontSize="small" />}
sx={{
borderRadius: 2,
py: 1, // 🔧 FIX: Reduced padding
'& .MuiAlert-message': {
fontSize: '0.8rem' // 🔧 FIX: Reduced font size
}
}}
>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom fontSize="0.85rem">
🔒 Login required
</Typography>
<Typography variant="body2" fontSize="0.75rem">
To upload publications you must be registered and logged in.
Click the user icon in the top right to login or register.
</Typography>
</Alert>
)}
{/* 🎯 NEW: Dynamic information about requirements */}
{!isDisabled && (
<Alert
severity="info"
icon={<InfoIcon fontSize="small" />}
sx={{
borderRadius: 2,
py: 1, // 🔧 FIX: Reduced padding
'& .MuiAlert-message': {
fontSize: '0.8rem' // 🔧 FIX: Reduced font size
}
}}
>
{bibtex ? (
<Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom fontSize="0.85rem">
📋 BibTeX mode active
</Typography>
<Typography variant="body2" fontSize="0.75rem">
You have uploaded a BibTeX file. Metadata will be automatically extracted from the BibTeX file, and the fields below become optional.
</Typography>
</Box>
) : (
<Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom fontSize="0.85rem">
📝 Manual mode active
</Typography>
<Typography variant="body2" fontSize="0.75rem">
Fill in all fields below or upload a BibTeX file for automatic metadata extraction.
</Typography>
</Box>
)}
</Alert>
)}
{/* Upload Files Section */}
<Paper
variant="outlined"
sx={{
p: 2, // 🔧 FIX: Reduced padding from 3 to 2
borderRadius: 3,
borderStyle: 'dashed',
borderWidth: 2,
bgcolor: isDisabled ? 'action.disabledBackground' : 'background.default',
borderColor: isDisabled ? 'action.disabled' : 'primary.main',
opacity: isDisabled ? 0.6 : 1,
'&:hover': {
borderColor: isDisabled ? 'action.disabled' : 'primary.main',
bgcolor: isDisabled ? 'action.disabledBackground' : 'primary.50'
}
}}
>
<Stack spacing={1.5}> {/* 🔧 FIX: Reduced spacing */}
<Typography
variant="subtitle2"
fontWeight="bold"
color={isDisabled ? "text.disabled" : "primary"}
fontSize="0.9rem" // 🔧 FIX: Reduced font size
>
📁 File to upload
</Typography>
<Button
variant="outlined"
component="label"
startIcon={<DescriptionIcon fontSize="small" />}
size="medium" // 🔧 FIX: Changed from large to medium
disabled={isDisabled}
sx={{
borderRadius: 2,
borderStyle: 'dashed',
py: 1.5, // 🔧 FIX: Reduced padding
textTransform: 'none',
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
}}
>
{file ? `📄 ${file.name}` : "Select PDF/DOCX/LaTeX document *"}
<input
type="file"
name="file"
accept=".pdf,.docx,.tex,.latex"
hidden
disabled={isDisabled}
onChange={handleFileChange}
/>
</Button>
<Button
variant="outlined"
component="label"
startIcon={<UploadFileIcon fontSize="small" />}
size="medium" // 🔧 FIX: Changed from large to medium
disabled={isDisabled}
sx={{
borderRadius: 2,
borderStyle: 'dashed',
py: 1.5, // 🔧 FIX: Reduced padding
textTransform: 'none',
fontSize: '0.85rem', // 🔧 FIX: Reduced font size
// 🎯 Highlight BibTeX if loaded
borderColor: bibtex ? 'success.main' : undefined,
backgroundColor: bibtex ? 'success.50' : undefined
}}
>
{bibtex ? `📋 ${bibtex.name}` : "BibTeX File (optional)"}
<input
type="file"
name="bibtex"
accept=".bib"
hidden
disabled={isDisabled}
onChange={handleBibtexChange}
/>
</Button>
</Stack>
</Paper>
{/* Form Fields - IMPROVED AND COMPACT LAYOUT */}
<Stack spacing={1.5}> {/* 🔧 FIX: Reduced spacing */}
<Typography
variant="subtitle2"
fontWeight="bold"
color={isDisabled ? "text.disabled" : "primary"}
fontSize="0.9rem" // 🔧 FIX: Reduced font size
>
📝 Publication information {fieldsRequired ? "(required)" : "(optional)"}
</Typography>
<TextField
name="title"
label={`Title${fieldsRequired ? ' *' : ''}`}
value={form.title}
onChange={handleChange}
required={fieldsRequired}
fullWidth
disabled={isDisabled}
multiline
rows={1} // 🔧 FIX: Reduced from 2 to 1 row
size="small" // 🔧 FIX: Added small size
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
}
}}
/>
<TextField
name="authors"
label={`Authors (separated by commas)${fieldsRequired ? ' *' : ''}`}
value={form.authors}
onChange={handleChange}
required={fieldsRequired}
fullWidth
disabled={isDisabled}
placeholder="John Smith, Jane Doe, Mark Johnson"
size="small" // 🔧 FIX: Added small size
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
}
}}
/>
{/* IMPROVED LAYOUT WITH GRID INSTEAD OF FLEX BOX */}
<Grid container spacing={1.5}> {/* 🔧 FIX: Reduced spacing */}
<Grid item xs={12} sm={4}>
<TextField
name="year"
label={`Year${fieldsRequired ? ' *' : ''}`}
type="number"
value={form.year}
onChange={handleChange}
required={fieldsRequired}
disabled={isDisabled}
fullWidth
size="small" // 🔧 FIX: Added small size
inputProps={{
min: 1900,
max: new Date().getFullYear() + 5
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
}
}}
/>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
name="journal"
label={`Journal/Conference${fieldsRequired ? ' *' : ''}`}
value={form.journal}
onChange={handleChange}
required={fieldsRequired}
disabled={isDisabled}
fullWidth
placeholder="Nature, IEEE Transactions, ICML 2024, etc."
size="small" // 🔧 FIX: Added small size
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
}
}}
/>
</Grid>
</Grid>
{/* NEW DOI FIELD - COMPACT */}
<TextField
name="doi"
label="DOI (optional)"
value={form.doi}
onChange={handleChange}
fullWidth
disabled={isDisabled}
placeholder="e.g. 10.1000/182"
helperText="Format: 10.xxxx/xxxxx (leave blank if not available)"
size="small" // 🔧 FIX: Added small size
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
},
'& .MuiFormHelperText-root': {
fontSize: '0.7rem' // 🔧 FIX: Reduced helper text font size
}
}}
inputProps={{
pattern: "^10\\.\\d{4,}/[-._;()/:\\w\\[\\]]+$"
}}
/>
</Stack>
{/* Result Section */}
{result && (
<Alert
severity={result.error ? "error" : "success"}
icon={result.error ? undefined : <CheckCircleIcon fontSize="small" />}
sx={{
borderRadius: 2,
py: 1, // 🔧 FIX: Reduced padding
'& .MuiAlert-message': {
fontSize: '0.8rem' // 🔧 FIX: Reduced font size
}
}}
>
{result.error ? (
result.error
) : (
<Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom fontSize="0.85rem">
🎉 Publication uploaded successfully!
</Typography>
<Typography variant="body2" fontSize="0.75rem">
"{result.title}" has been added to the database. The window will close automatically and the page will be refreshed.
</Typography>
</Box>
)}
</Alert>
)}
</Stack>
</DialogContent>
<Divider sx={{ flexShrink: 0 }} />
{/* 🔧 FIX: Compact DialogActions */}
<DialogActions sx={{ p: 2, gap: 1.5, flexShrink: 0 }}> {/* 🔧 FIX: Reduced padding and gap */}
<Button
onClick={handleClose}
variant="outlined"
size="medium" // 🔧 FIX: Changed from large to medium
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 80, // 🔧 FIX: Reduced minWidth
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
}}
>
{isDisabled ? "Close" : "Cancel"}
</Button>
{isDisabled ? (
<Button
variant="contained"
startIcon={<LoginIcon fontSize="small" />}
size="medium" // 🔧 FIX: Changed from large to medium
onClick={handleClose}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 120, // 🔧 FIX: Reduced minWidth
fontSize: '0.85rem', // 🔧 FIX: Reduced font size
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #eab308 0%, #ca8a04 100%)',
}
}}
>
Go to Login
</Button>
) : (
<Button
type="submit"
variant="contained"
disabled={loading || !file || !isFormValid || result?.id}
size="medium" // 🔧 FIX: Changed from large to medium
startIcon={<PublishIcon fontSize="small" />}
sx={{
borderRadius: 2,
textTransform: 'none',
minWidth: 120, // 🔧 FIX: Reduced minWidth
fontSize: '0.85rem', // 🔧 FIX: Reduced font size
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #5a67d8 0%, #6b4c93 100%)',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)'
},
transition: 'all 0.3s ease'
}}
>
{loading ? "Uploading..." : "Upload publication"}
</Button>
)}
</DialogActions>
</form>
</Dialog>
);
}

View file

@ -0,0 +1,191 @@
import { useState } from "react";
import { Button, Menu, MenuItem, Avatar, Typography, Box, IconButton } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import LoginDialog from "./LoginDialog";
import LoginIcon from '@mui/icons-material/Login';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import PersonIcon from '@mui/icons-material/Person';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import LogoutIcon from '@mui/icons-material/Logout';
export default function UserMenu() {
const [anchorEl, setAnchorEl] = useState(null);
const [loginOpen, setLoginOpen] = useState(false);
const { token, logout, userInfo, loading } = useAuth();
const navigate = useNavigate();
const handleMenu = (e) => setAnchorEl(e.currentTarget);
const handleClose = () => setAnchorEl(null);
// Function to get display name
const getDisplayName = () => {
if (!userInfo) return "User";
// If has first and last name, show those
if (userInfo.first_name && userInfo.last_name) {
return `${userInfo.first_name} ${userInfo.last_name}`;
}
// If has only first name
if (userInfo.first_name) {
return userInfo.first_name;
}
// Otherwise show email (or part of it)
if (userInfo.email) {
return userInfo.email.split('@')[0];
}
return "User";
};
// Initials for avatar
const getInitials = () => {
if (!userInfo) return "U";
if (userInfo.first_name && userInfo.last_name) {
return `${userInfo.first_name.charAt(0)}${userInfo.last_name.charAt(0)}`.toUpperCase();
}
if (userInfo.first_name) {
return userInfo.first_name.charAt(0).toUpperCase();
}
if (userInfo.email) {
return userInfo.email.charAt(0).toUpperCase();
}
return "U";
};
return (
<>
{!token ? (
// Login button when not logged in
<Button
variant="contained"
startIcon={<LoginIcon />}
onClick={() => setLoginOpen(true)}
sx={{
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.15)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)',
color: 'white',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.25)',
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(0,0,0,0.3)'
},
fontWeight: 'bold',
textTransform: 'none',
transition: 'all 0.3s ease'
}}
>
Login
</Button>
) : (
// Button with name when logged in
<Button
onClick={handleMenu}
endIcon={<ExpandMoreIcon />}
disabled={loading}
sx={{
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.15)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)',
color: 'white',
'&:hover': {
backgroundColor: 'rgba(255,255,255,0.25)',
},
textTransform: 'none',
transition: 'all 0.3s ease',
maxWidth: '220px',
'&.Mui-disabled': {
backgroundColor: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.7)',
}
}}
>
<Box display="flex" alignItems="center" gap={1}>
<Avatar sx={{
width: 28,
height: 28,
fontSize: '0.75rem',
bgcolor: 'rgba(255,255,255,0.3)',
color: 'white'
}}>
{getInitials()}
</Avatar>
<Typography
variant="body2"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '140px',
fontWeight: 500
}}
>
{loading ? "Loading..." : getDisplayName()}
</Typography>
</Box>
</Button>
)}
{/* Dropdown menu for logged in user */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
PaperProps={{
sx: {
mt: 1,
borderRadius: 2,
minWidth: 200,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
}
}}
>
{/* User info in menu */}
{userInfo && (
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="body2" fontWeight="bold" color="text.primary">
{getDisplayName()}
</Typography>
<Typography variant="caption" color="text.secondary">
{userInfo.email}
</Typography>
</Box>
)}
<MenuItem
onClick={() => {
navigate("/me");
handleClose();
}}
sx={{ gap: 1.5, py: 1.5 }}
>
<MenuBookIcon fontSize="small" />
My publications
</MenuItem>
<MenuItem
onClick={() => {
logout();
handleClose();
}}
sx={{ gap: 1.5, py: 1.5, color: 'error.main' }}
>
<LogoutIcon fontSize="small" />
Logout
</MenuItem>
</Menu>
{/* Login dialog */}
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
</>
);
}

7
frontend/src/config.js Normal file
View file

@ -0,0 +1,7 @@
const API_URL = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:8000';
const config = {
API_URL
};
export default config;
export { API_URL };

View file

@ -0,0 +1,69 @@
import { createContext, useContext, useState, useEffect } from "react";
import { API_URL } from "../api/client";
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem("access_token") || null);
const [userInfo, setUserInfo] = useState(null);
const [loading, setLoading] = useState(false);
// Funzione per recuperare info utente
const fetchUserInfo = async (authToken) => {
if (!authToken) return;
setLoading(true);
try {
const response = await fetch(`${API_URL}/users/me`, {
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const userData = await response.json();
setUserInfo(userData);
} else {
console.error('Errore nel recupero info utente:', response.status);
}
} catch (error) {
console.error('Errore nel recupero info utente:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) {
localStorage.setItem("access_token", token);
fetchUserInfo(token);
} else {
localStorage.removeItem("access_token");
setUserInfo(null);
}
}, [token]);
function logout() {
setToken(null);
setUserInfo(null);
}
return (
<AuthContext.Provider value={{
token,
setToken,
logout,
userInfo,
setUserInfo,
loading,
refetchUserInfo: () => fetchUserInfo(token)
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View file

@ -0,0 +1,40 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { lightTheme, darkTheme } from '../theme/theme';
const ThemeContext = createContext();
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
export const CustomThemeProvider = ({ children }) => {
const [darkMode, setDarkMode] = useState(() => {
const saved = localStorage.getItem('darkMode');
return saved ? JSON.parse(saved) : false;
});
useEffect(() => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}, [darkMode]);
const toggleDarkMode = () => {
setDarkMode(prev => !prev);
};
const theme = darkMode ? darkTheme : lightTheme;
return (
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</ThemeContext.Provider>
);
};

38
frontend/src/index.css Normal file
View file

@ -0,0 +1,38 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f8fafc;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0,0,0,0.1);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.6);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.8);
}
* {
transition: color 0.3s ease, background-color 0.3s ease;
}

16
frontend/src/index.js Normal file
View file

@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "./index.css";
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

17
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,17 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import CssBaseline from "@mui/material/CssBaseline";
import "./index.css";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<CssBaseline />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,23 @@
import { useState } from "react";
import SearchBar from "../components/SearchBar";
import PublicationList from "../components/PublicationList";
import UploadModal from "../components/UploadModal";
import { Box } from "@mui/material";
export default function HomePage() {
const [search, setSearch] = useState("");
const [uploadOpen, setUploadOpen] = useState(false);
const [refresh, setRefresh] = useState(false);
return (
<Box sx={{ pb: 4 }}>
<SearchBar value={search} onChange={setSearch} />
<PublicationList search={search} key={refresh} />
<UploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUploadSuccess={() => setRefresh(r => !r)}
/>
</Box>
);
}

View file

@ -0,0 +1,486 @@
import { useAuth } from "../context/AuthContext";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchUserPublications, deletePublication } from "../api/publications";
import {
CircularProgress,
Alert,
Card,
CardContent,
Typography,
Container,
Box,
Chip,
Grid,
Stack,
Button,
Divider,
ButtonGroup,
IconButton,
Tooltip
} from "@mui/material";
import { useState } from "react";
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import PersonIcon from '@mui/icons-material/Person';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import SortIcon from '@mui/icons-material/Sort';
import SortByAlphaIcon from '@mui/icons-material/SortByAlpha';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import DeleteIcon from '@mui/icons-material/Delete';
import LinkIcon from '@mui/icons-material/Link';
import DeletePublicationDialog from "../components/DeletePublicationDialog";
import config from '../config';
export default function UserPublicationsPage() {
const { token } = useAuth();
const [orderBy, setOrderBy] = useState("date_desc");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [publicationToDelete, setPublicationToDelete] = useState(null);
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ["userPublications", { token, orderBy }],
queryFn: fetchUserPublications,
});
// Mutation to delete a publication
const deleteMutation = useMutation({
mutationFn: ({ publicationId }) => deletePublication({ publicationId, token }),
onSuccess: (data, variables) => {
// Invalidate and reload publications list
queryClient.invalidateQueries({ queryKey: ["userPublications"] });
setDeleteDialogOpen(false);
setPublicationToDelete(null);
},
onError: (error) => {
console.error("Error deleting:", error);
}
});
// Function to open the confirmation dialog
const handleDeleteClick = (publication) => {
setPublicationToDelete(publication);
setDeleteDialogOpen(true);
};
// Function to confirm deletion
const handleConfirmDelete = (publicationId) => {
deleteMutation.mutate({ publicationId });
};
// Function to get current sort information
const getOrderInfo = () => {
switch(orderBy) {
case "date_asc": return { type: "date", direction: "asc", label: "Date ascending", icon: <ArrowUpwardIcon /> };
case "date_desc": return { type: "date", direction: "desc", label: "Date descending", icon: <ArrowDownwardIcon /> };
case "title_asc": return { type: "title", direction: "asc", label: "Title A-Z", icon: <ArrowUpwardIcon /> };
case "title_desc": return { type: "title", direction: "desc", label: "Title Z-A", icon: <ArrowDownwardIcon /> };
default: return { type: "date", direction: "desc", label: "Date descending", icon: <ArrowDownwardIcon /> };
}
};
// Function to handle sort button clicks
const handleSort = (type) => {
const currentOrder = getOrderInfo();
if (currentOrder.type === type) {
// If same type, toggle direction
const newDirection = currentOrder.direction === "asc" ? "desc" : "asc";
setOrderBy(`${type}_${newDirection}`);
} else {
// If different type, set default direction
const defaultDirection = type === "date" ? "desc" : "asc";
setOrderBy(`${type}_${defaultDirection}`);
}
};
if (isLoading) {
return (
<Container maxWidth="lg">
<Box display="flex" justifyContent="center" mt={4}>
<CircularProgress size={60} />
</Box>
</Container>
);
}
if (error) {
return (
<Container maxWidth="lg">
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
{error.message}
</Alert>
</Container>
);
}
const currentOrder = getOrderInfo();
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold', color: 'primary.main' }}>
📚 My Publications
</Typography>
<Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
<Typography variant="h6" color="text.secondary">
{data?.length || 0} publications uploaded
</Typography>
{data?.length > 0 && (
<Chip
label={`Latest: ${new Date(data[0]?.upload_date).toLocaleDateString()}`}
color="primary"
variant="outlined"
size="small"
/>
)}
</Box>
{/* Sort controls */}
{data?.length > 1 && (
<Box display="flex" alignItems="center" gap={2}>
<Box display="flex" alignItems="center" gap={1}>
<SortIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
Sort:
</Typography>
</Box>
<ButtonGroup variant="outlined" size="small">
{/* Date button */}
<Button
variant={currentOrder.type === "date" ? "contained" : "outlined"}
onClick={() => handleSort("date")}
startIcon={currentOrder.type === "date" ? currentOrder.icon : <CalendarTodayIcon />}
sx={{
borderRadius: '8px 0 0 8px',
textTransform: 'none',
fontWeight: currentOrder.type === "date" ? 'bold' : 'normal',
minWidth: '100px'
}}
>
{currentOrder.type === "date" ? currentOrder.label : "Date"}
</Button>
{/* Title button */}
<Button
variant={currentOrder.type === "title" ? "contained" : "outlined"}
onClick={() => handleSort("title")}
startIcon={currentOrder.type === "title" ? currentOrder.icon : <SortByAlphaIcon />}
sx={{
borderRadius: '0 8px 8px 0',
textTransform: 'none',
fontWeight: currentOrder.type === "title" ? 'bold' : 'normal',
minWidth: '100px'
}}
>
{currentOrder.type === "title" ? currentOrder.label : "Title"}
</Button>
</ButtonGroup>
</Box>
)}
</Box>
{/* Active sort indicator */}
{data?.length > 1 && (
<Box sx={{ mt: 2 }}>
<Chip
label={`Sort by: ${currentOrder.label}`}
size="small"
color="primary"
variant="outlined"
icon={currentOrder.icon}
sx={{
fontWeight: 'medium',
'& .MuiChip-icon': {
fontSize: '1rem'
}
}}
/>
</Box>
)}
</Box>
{/* Delete error alert */}
{deleteMutation.error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{deleteMutation.error.message}
</Alert>
)}
{/* Delete success alert */}
{deleteMutation.isSuccess && (
<Alert severity="success" sx={{ mb: 3, borderRadius: 2 }}>
Publication successfully deleted!
</Alert>
)}
{/* Publications */}
{data?.length > 0 ? (
<Grid container spacing={3}>
{data.map(pub => (
<Grid item xs={12} key={pub.id}>
<Card
elevation={2}
sx={{
borderRadius: 3,
transition: 'all 0.3s ease',
border: '1px solid',
borderColor: 'primary.100',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(0,0,0,0.12)',
borderColor: 'primary.300'
}
}}
>
<CardContent sx={{ p: 3 }}>
{/* Header with title and delete button */}
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 2 }}>
<Typography
variant="h6"
component="h2"
sx={{
fontWeight: 'bold',
color: 'primary.main',
lineHeight: 1.3,
flex: 1,
mr: 2
}}
>
{pub.title}
</Typography>
<Tooltip title="Delete publication">
<IconButton
onClick={() => handleDeleteClick(pub)}
color="error"
size="small"
sx={{
'&:hover': {
backgroundColor: 'error.50',
}
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* Main information */}
<Stack direction="row" spacing={3} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
{/* Date */}
<Box display="flex" alignItems="center" gap={1}>
<CalendarTodayIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
Uploaded on {new Date(pub.upload_date).toLocaleDateString()}
{pub.year && ` (Year: ${pub.year})`}
</Typography>
</Box>
{/* File info */}
{pub.filename && (
<Box display="flex" alignItems="center" gap={1}>
<PictureAsPdfIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{pub.filename}
</Typography>
</Box>
)}
</Stack>
{/* Journal/Conference */}
{pub.journal && (
<Box sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
<MenuBookIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary" fontWeight="medium">
Published in:
</Typography>
</Box>
<Chip
label={pub.journal}
variant="outlined"
sx={{
ml: 3,
backgroundColor: 'info.50',
borderColor: 'info.200',
color: 'info.700',
fontWeight: 'medium',
'&:hover': {
backgroundColor: 'info.100',
}
}}
/>
</Box>
)}
{/* Keywords */}
{pub.keywords && pub.keywords.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
<LocalOfferIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary" fontWeight="medium">
Keywords:
</Typography>
</Box>
<Box display="flex" flexWrap="wrap" gap={1} sx={{ ml: 3 }}>
{pub.keywords.map((keyword, index) => (
<Chip
key={index}
label={keyword.name}
size="small"
sx={{
backgroundColor: 'secondary.50',
color: 'secondary.700',
fontWeight: 'medium',
'&:hover': {
backgroundColor: 'secondary.100',
}
}}
/>
))}
</Box>
</Box>
)}
{/* DOI */}
{pub.doi && (
<Box sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
<LinkIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary" fontWeight="medium">
DOI:
</Typography>
</Box>
<Chip
label={pub.doi}
variant="outlined"
component="a"
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
clickable
sx={{
ml: 3,
backgroundColor: 'success.50',
borderColor: 'success.200',
color: 'success.700',
fontWeight: 'medium',
textDecoration: 'none',
'&:hover': {
backgroundColor: 'success.100',
textDecoration: 'none',
}
}}
/>
</Box>
)}
{/* Authors */}
{pub.authors && pub.authors.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
<PersonIcon fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary" fontWeight="medium">
Authors:
</Typography>
</Box>
<Typography variant="body2" color="text.primary" sx={{ ml: 3 }}>
{pub.authors.map(a => a.name).join(", ")}
</Typography>
</Box>
)}
{/* Alert if no keywords */}
{(!pub.keywords || pub.keywords.length === 0) && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
This publication has no keywords! The search system relies on keywords.
</Typography>
</Alert>
)}
<Divider sx={{ my: 2 }} />
{/* Actions */}
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="caption" color="text.disabled">
Publication ID: #{pub.id}
</Typography>
<Button
variant="contained"
startIcon={<PictureAsPdfIcon />}
href={`${config.API_URL}/download/${pub.id}`}
target="_blank"
rel="noopener noreferrer"
size="small"
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold'
}}
>
View PDF
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
) : (
/* Empty state */
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
sx={{
py: 8,
textAlign: 'center'
}}
>
<CloudUploadIcon sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
You haven't uploaded any publications yet
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Use the UPLOAD button in the header to add your first publication
</Typography>
<Button
variant="contained"
startIcon={<CloudUploadIcon />}
onClick={() => window.location.href = '/'}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 'bold'
}}
>
Go to homepage
</Button>
</Box>
)}
{/* Delete confirmation dialog */}
<DeletePublicationDialog
open={deleteDialogOpen}
onClose={() => {
setDeleteDialogOpen(false);
setPublicationToDelete(null);
}}
publication={publicationToDelete}
onConfirmDelete={handleConfirmDelete}
loading={deleteMutation.isPending}
/>
</Container>
);
}

277
frontend/src/theme/theme.js Normal file
View file

@ -0,0 +1,277 @@
import { createTheme } from '@mui/material/styles';
const colors = {
primary: {
50: '#f0f4ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
},
secondary: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
}
};
export const lightTheme = createTheme({
palette: {
mode: 'light',
primary: {
main: colors.primary[600],
light: colors.primary[400],
dark: colors.primary[800],
contrastText: '#ffffff',
},
secondary: {
main: colors.secondary[600],
light: colors.secondary[400],
dark: colors.secondary[800],
contrastText: '#ffffff',
},
background: {
default: '#f8fafc',
paper: '#ffffff',
card: '#ffffff',
},
text: {
primary: '#1e293b',
secondary: '#64748b',
},
grey: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
},
shape: {
borderRadius: 12,
},
typography: {
fontFamily: [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
h4: {
fontWeight: 700,
letterSpacing: '-0.025em',
},
h5: {
fontWeight: 600,
letterSpacing: '-0.025em',
},
h6: {
fontWeight: 600,
letterSpacing: '-0.025em',
},
},
components: {
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
border: '1px solid rgba(226, 232, 240, 0.8)',
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: 12,
textTransform: 'none',
fontWeight: 600,
boxShadow: 'none',
'&:hover': {
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.4)',
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 8,
fontWeight: 500,
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: 12,
},
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 20,
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
},
},
},
},
});
export const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: colors.primary[400],
light: colors.primary[300],
dark: colors.primary[600],
contrastText: '#000000',
},
secondary: {
main: colors.secondary[400],
light: colors.secondary[300],
dark: colors.secondary[600],
contrastText: '#000000',
},
background: {
default: '#0f172a',
paper: '#1e293b',
card: '#334155',
},
text: {
primary: '#f8fafc',
secondary: '#cbd5e1',
},
grey: {
50: '#0f172a',
100: '#1e293b',
200: '#334155',
300: '#475569',
400: '#64748b',
500: '#94a3b8',
600: '#cbd5e1',
700: '#e2e8f0',
800: '#f1f5f9',
900: '#f8fafc',
},
},
shape: {
borderRadius: 12,
},
typography: {
fontFamily: [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
h4: {
fontWeight: 700,
letterSpacing: '-0.025em',
},
h5: {
fontWeight: 600,
letterSpacing: '-0.025em',
},
h6: {
fontWeight: 600,
letterSpacing: '-0.025em',
},
},
components: {
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
backgroundColor: '#1e293b',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
border: '1px solid rgba(71, 85, 105, 0.6)',
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: 12,
textTransform: 'none',
fontWeight: 600,
boxShadow: 'none',
'&:hover': {
boxShadow: '0 4px 12px rgba(129, 140, 248, 0.4)',
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 8,
fontWeight: 500,
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: 12,
backgroundColor: 'rgba(51, 65, 85, 0.5)',
'& fieldset': {
borderColor: 'rgba(71, 85, 105, 0.6)',
},
'&:hover fieldset': {
borderColor: 'rgba(129, 140, 248, 0.6)',
},
},
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 20,
backgroundColor: '#1e293b',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
border: '1px solid rgba(71, 85, 105, 0.6)',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: 'transparent',
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.9) 0%, rgba(139, 92, 246, 0.9) 100%)',
backdropFilter: 'blur(20px)',
},
},
},
},
});