mirror of
https://github.com/fccapria/scientify.git
synced 2026-01-12 02:36:10 +00:00
Initial release
This commit is contained in:
commit
ae5e4b8873
52 changed files with 17572 additions and 0 deletions
1
frontend/.env.production
Normal file
1
frontend/.env.production
Normal file
|
|
@ -0,0 +1 @@
|
|||
REACT_APP_API_URL=/api
|
||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal 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
30
frontend/nginx.conf
Normal 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
54
frontend/package.json
Normal 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
12629
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
37
frontend/public/favicon.svg
Normal file
37
frontend/public/favicon.svg
Normal 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 |
36
frontend/public/index.html
Normal file
36
frontend/public/index.html
Normal 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
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
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
22
frontend/public/manifest.json
Normal file
22
frontend/public/manifest.json
Normal 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"]
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
30
frontend/src/App.js
Normal file
30
frontend/src/App.js
Normal 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
67
frontend/src/api/auth.js
Normal 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();
|
||||
}
|
||||
44
frontend/src/api/client.js
Normal file
44
frontend/src/api/client.js
Normal 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);
|
||||
}
|
||||
}
|
||||
5
frontend/src/api/config.js
Normal file
5
frontend/src/api/config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import config from '../config';
|
||||
|
||||
export const API_URL = config.API_URL;
|
||||
|
||||
// Il resto del file rimane invariato
|
||||
54
frontend/src/api/publications.js
Normal file
54
frontend/src/api/publications.js
Normal 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();
|
||||
}
|
||||
132
frontend/src/components/AppBarHeader.jsx
Normal file
132
frontend/src/components/AppBarHeader.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
frontend/src/components/DeletePublicationDialog.jsx
Normal file
130
frontend/src/components/DeletePublicationDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
370
frontend/src/components/LoginDialog.jsx
Normal file
370
frontend/src/components/LoginDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
frontend/src/components/PublicationList.jsx
Normal file
379
frontend/src/components/PublicationList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/SearchBar.jsx
Normal file
51
frontend/src/components/SearchBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
567
frontend/src/components/UploadModal.jsx
Normal file
567
frontend/src/components/UploadModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/UserMenu.jsx
Normal file
191
frontend/src/components/UserMenu.jsx
Normal 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
7
frontend/src/config.js
Normal 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 };
|
||||
69
frontend/src/context/AuthContext.jsx
Normal file
69
frontend/src/context/AuthContext.jsx
Normal 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);
|
||||
}
|
||||
40
frontend/src/context/ThemeContext.jsx
Normal file
40
frontend/src/context/ThemeContext.jsx
Normal 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
38
frontend/src/index.css
Normal 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
16
frontend/src/index.js
Normal 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
17
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
23
frontend/src/pages/HomePage.jsx
Normal file
23
frontend/src/pages/HomePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
486
frontend/src/pages/UserPublicationsPage.jsx
Normal file
486
frontend/src/pages/UserPublicationsPage.jsx
Normal 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
277
frontend/src/theme/theme.js
Normal 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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue