Initial release

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

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
*~
backend/.venv/
*/__pycache__/
*/*/__pycache__/
*/*/*/__pycache__/
frontend/build
frontend/node_modules

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Francesco Carmelo Capria
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

167
README.md Normal file
View file

@ -0,0 +1,167 @@
# 🧬 Scientify
**The intelligent platform to manage your scientific publications** 📚✨
Scientify is a modern web application that allows you to organize, catalog, and easily search your scientific publications. With advanced automatic document processing features and an intuitive interface, making the management of your scientific library a pleasant experience!
---
## 🚀 Main Features
### 📤 **Smart Upload**
- **Multi-format upload**: PDF, DOCX, LaTeX - everything gets automatically converted to PDF
- **Automatic metadata extraction**: from BibTeX files or manual compilation
- **Document conversion**: Advanced DOCX support with Pandoc and Mammoth
### 🔍 **Advanced Search**
- **Keyword search**: Quickly find the publications you need
- **Smart filters**: By author, year, journal, DOI
- **Flexible sorting**: By date, title, relevance
### 🤖 **NLP & Keyword Extraction**
- **Automatic keyword extraction**: Natural Language Processing algorithms automatically analyze your documents
- **Smart categorization**: Publications are automatically organized by topic
- **Advanced tagging**: Label system for precise cataloging
### 👥 **User Management**
- **Secure registration and authentication**: Complete account management system
- **Personal library**: Each user has access to their own private collection
- **Granular permissions**: Complete control over your documents
### 📊 **Modern Interface**
- **Responsive design**: Works perfectly on desktop, tablet, and mobile
- **Intuitive UI/UX**: Simple and pleasant navigation
- **Material-UI components**: Modern and accessible design
---
## 🛠️ Tech Stack
### Frontend 🎨
- **React 18**: Modern framework for reactive UIs
- **Material-UI**: Elegant and accessible components
- **Tanstack Query**: Optimized state management and caching
- **JavaScript ES6+**: Modern and performant code
### Backend ⚡
- **FastAPI**: High-performance Python framework
- **SQLAlchemy**: Robust ORM for database management
- **PostgreSQL**: Scalable relational database
- **Async/Await**: Asynchronous programming for maximum performance
### AI & NLP 🧠
- **Keyword Extraction**: Intelligent extraction using YAKE
---
## 🐳 Setup & Installation
Scientify supports two deployment modes: **Docker** (recommended) and **manual setup**.
### 🚢 Docker (Easiest way!)
```bash
# Clone the repository
git clone https://github.com/fccapria/scientify.git
cd scientify
# Start everything with Docker Compose
docker-compose up -d
# Your app will be available at http://localhost:80
```
### 🔧 Manual Setup
#### Backend (Python + FastAPI)
```bash
cd backend
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Configure database and environment variables
cp .env.example .env
# Edit .env with your configurations
# Start the server
uvicorn app.main:app --reload
```
#### Frontend (React)
```bash
cd frontend
# Install dependencies with pnpm (faster!)
pnpm install
# Or with npm: npm install
# Start the dev server
pnpm start
# Or: npm start
```
---
## 🎯 How It Works
1. **📝 Register** and create your personal account
2. **📤 Upload** your publications (PDF, DOCX, LaTeX)
3. **📋 Add metadata** manually or upload a BibTeX file
4. **🤖 Let the AI** automatically extract keywords
5. **🔍 Search and organize** your scientific library
6. **📊 Monitor** your collection with advanced statistics
---
## 🌟 Roadmap
- [ ] 📱 Native mobile app
- [ ] 🔗 Integration with arXiv and PubMed
- [ ] 📈 Advanced analytics and citation metrics
- [ ] 🤝 Collaboration and sharing between users
- [ ] 🌐 Public API for integrations
- [ ] 📧 Email notifications for new publications
---
## 🤝 Contributing
We'd love your help to make Scientify even better!
1. 🍴 **Fork** the project
2. 🌿 **Create** a branch for your feature (`git checkout -b feature/new-feature`)
3. ✨ **Commit** your changes (`git commit -m 'Add new feature'`)
4. 📤 **Push** to the branch (`git push origin feature/new-feature`)
5. 🔀 **Open** a Pull Request
---
## 📄 License
This project is released under the MIT license. See the `LICENSE` file for details.
---
## 🚨 Support
Having problems or suggestions?
- 🐛 [Open an Issue](https://github.com/fccapria/scientify/issues)
- 💬 [Discussions](https://github.com/fccapria/scientify/discussions)
- 📧 Email: [francesco@capria.eu]
---
<div align="center">
**Created with ❤️ by [@fccapria](https://github.com/fccapria)**
</div>
---
---

8
backend/.env Normal file
View file

@ -0,0 +1,8 @@
POSTGRES_USER=scientify_user
POSTGRES_PASSWORD=scientify_pass
POSTGRES_DB=scientify_db
POSTGRES_HOST=db
POSTGRES_PORT=5432
# Database URL for local development with Docker
DATABASE_URL=postgresql+asyncpg://scientify_user:scientify_pass@db:5432/scientify_db

25
backend/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libpq-dev \
libffi-dev \
libxml2-dev \
libxslt1-dev \
libcairo2-dev \
libpango1.0-dev \
libgdk-pixbuf2.0-dev \
libgtk-3-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "main.py"]

82
backend/app/app.py Normal file
View file

@ -0,0 +1,82 @@
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.db import User, create_db_and_tables
from app.schemas import UserCreate, UserRead, UserUpdate
from app.users import auth_backend, current_active_user, fastapi_users
from app.upload import router as upload_router
from app.download import router as download_router
from app.publication_routes import router as publication_router
from app.debug_routes import router as debug_router
@asynccontextmanager
async def lifespan(app: FastAPI):
await create_db_and_tables()
yield
app = FastAPI(
title="Scientify API",
description="API for managing scientific publications",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://frontend:80"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers for different parts of the application
app.include_router(upload_router)
app.include_router(download_router)
app.include_router(publication_router)
app.include_router(debug_router)
# Authentication and user management routes
app.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
@app.get("/authenticated-route")
async def authenticated_route(user: User = Depends(current_active_user)):
return {"message": f"Hello {user.email}!"}
@app.get("/")
async def root():
"""
Root endpoint for the Scientify API
"""
return {
"message": "Welcome to Scientify API",
"description": "The intelligent platform to manage your scientific publications",
"documentation": "/docs"
}

100
backend/app/db.py Normal file
View file

@ -0,0 +1,100 @@
import os
from collections.abc import AsyncGenerator
from fastapi import Depends
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
from sqlalchemy import Column, Integer, String, Table, ForeignKey, LargeBinary, DateTime
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker
from sqlalchemy.dialects.postgresql import UUID
import uuid
import datetime
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://scientify_user:scientify_pass@db:5432/scientify_db")
if not "+asyncpg" in DATABASE_URL:
raise ValueError("DATABASE_URL must use asyncpg driver for async operations. Use postgresql+asyncpg://...")
class Base(DeclarativeBase):
pass
class User(SQLAlchemyBaseUserTableUUID, Base):
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
publications = relationship("Publication", back_populates="user")
try:
engine = create_async_engine(DATABASE_URL, echo=True)
print("Database engine created successfully")
except Exception as e:
print(f"Error creating database engine: {e}")
raise
async_session_maker = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async def get_db():
async with async_session_maker() as session:
yield session
async def create_db_and_tables():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def get_async_session():
async with async_session_maker() as session:
yield session
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User)
publication_authors = Table(
'publication_authors', Base.metadata,
Column('publication_id', Integer, ForeignKey('publications.id', ondelete='CASCADE')),
Column('author_id', Integer, ForeignKey('authors.id', ondelete='CASCADE'))
)
publication_keywords = Table(
'publication_keywords', Base.metadata,
Column('publication_id', Integer, ForeignKey('publications.id', ondelete='CASCADE')),
Column('keyword_id', Integer, ForeignKey('keywords.id', ondelete='CASCADE'))
)
class Author(Base):
__tablename__ = 'authors'
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
class Keyword(Base):
__tablename__ = 'keywords'
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
class Publication(Base):
__tablename__ = 'publications'
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
file = Column(LargeBinary, nullable=False)
filename = Column(String)
upload_date = Column(DateTime, default=datetime.datetime.utcnow)
journal = Column(String, nullable=True)
year = Column(Integer, nullable=True)
doi = Column(String, nullable=True, unique=True)
user_id = Column(UUID(as_uuid=True), ForeignKey('user.id'), nullable=False)
user = relationship("User", back_populates="publications")
authors = relationship('Author', secondary=publication_authors, backref='publications')
keywords = relationship('Keyword', secondary=publication_keywords, backref='publications')

View file

@ -0,0 +1,59 @@
from fastapi import Depends, APIRouter
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Publication, get_db, Keyword, Author
router = APIRouter(prefix="/debug", tags=["debug"])
# Debug endpoint to view all publications with complete data
@router.get("/publications")
async def debug_publications(db: AsyncSession = Depends(get_db)):
"""Debug endpoint to view all publications with their complete data"""
stmt = select(Publication).options(
selectinload(Publication.authors),
selectinload(Publication.keywords),
selectinload(Publication.user)
).order_by(Publication.upload_date.desc())
result = await db.execute(stmt)
publications = result.scalars().all()
debug_data = []
for pub in publications:
debug_data.append({
"id": pub.id,
"title": pub.title,
"authors": [{"id": a.id, "name": a.name} for a in pub.authors],
"keywords": [{"id": k.id, "name": k.name} for k in pub.keywords], # 🎯 KEYWORDS!
"upload_date": pub.upload_date,
"journal": pub.journal,
"year": pub.year,
"doi": pub.doi,
"user_email": pub.user.email if pub.user else None,
"user_id": str(pub.user_id) if pub.user_id else None
})
return {
"total_publications": len(publications),
"publications": debug_data
}
# Debug endpoint to view all authors
@router.get("/authors")
async def debug_authors(db: AsyncSession = Depends(get_db)):
"""Debug endpoint to view all authors"""
result = await db.execute(select(Author))
authors = result.scalars().all()
return [{"id": a.id, "name": a.name} for a in authors]
# Debug endpoint to view all keywords
@router.get("/keywords")
async def debug_keywords(db: AsyncSession = Depends(get_db)):
"""🎯 Debug endpoint to view all keywords - THE HEART OF THE SYSTEM!"""
result = await db.execute(select(Keyword))
keywords = result.scalars().all()
return [{"id": k.id, "name": k.name} for k in keywords]

26
backend/app/download.py Normal file
View file

@ -0,0 +1,26 @@
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.db import Publication, get_db
import io
router = APIRouter()
@router.get("/download/{publication_id}")
async def download_publication(publication_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Publication).where(Publication.id == publication_id))
publication = result.scalar_one_or_none()
if not publication:
raise HTTPException(status_code=404, detail="Publication not found")
file_bytes = publication.file
filename = publication.filename or "document.pdf"
return StreamingResponse(
io.BytesIO(file_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)

View file

@ -0,0 +1,362 @@
import os
import tempfile
from pathlib import Path
from typing import Tuple, Optional
from io import BytesIO
import logging
import re
from docx import Document as DocxDocument
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter, A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib.units import inch
from weasyprint import HTML, CSS
import mammoth
logger = logging.getLogger(__name__)
class FileConverter:
@staticmethod
def get_file_extension(filename: str) -> str:
return Path(filename).suffix.lower()
@staticmethod
def convert_docx_to_pdf_reportlab(docx_content: bytes, original_filename: str) -> Tuple[bytes, str]:
try:
with tempfile.TemporaryDirectory() as temp_dir:
# Saves docx
docx_path = os.path.join(temp_dir, "temp.docx")
with open(docx_path, "wb") as f:
f.write(docx_content)
# Reads docx
doc = DocxDocument(docx_path)
# Creates pdf
pdf_path = os.path.join(temp_dir, "output.pdf")
FileConverter._create_pdf_from_docx(doc, pdf_path)
# Reads pdf
with open(pdf_path, "rb") as f:
pdf_content = f.read()
# Creates filename
new_filename = original_filename.replace('.docx', '.pdf')
return pdf_content, new_filename
except Exception as e:
return FileConverter.convert_docx_to_pdf_mammoth(docx_content, original_filename)
@staticmethod
def convert_docx_to_pdf_mammoth(docx_content: bytes, original_filename: str) -> Tuple[bytes, str]:
try:
with tempfile.TemporaryDirectory() as temp_dir:
# Saves docx
docx_path = os.path.join(temp_dir, "temp.docx")
with open(docx_path, "wb") as f:
f.write(docx_content)
# Converts in HTML
with open(docx_path, "rb") as docx_file:
result = mammoth.convert_to_html(docx_file)
html_content = result.value
# Creates HTML
full_html = FileConverter._wrap_html_with_styles(html_content, "DOCX Document")
# Converts to PDF
pdf_bytes = FileConverter._html_to_pdf(full_html)
new_filename = original_filename.replace('.docx', '.pdf')
return pdf_bytes, new_filename
except Exception as e:
raise Exception(f"Impossible to convert from DOCX to PDF: {str(e)}")
@staticmethod
def _create_pdf_from_docx(docx_doc, output_path: str):
doc = SimpleDocTemplate(output_path, pagesize=A4)
styles = getSampleStyleSheet()
story = []
# Custom styles
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=16,
spaceAfter=12,
textColor='black'
)
normal_style = ParagraphStyle(
'CustomNormal',
parent=styles['Normal'],
fontSize=11,
spaceAfter=6,
textColor='black'
)
for paragraph in docx_doc.paragraphs:
if paragraph.text.strip():
if len(paragraph.text) < 100 and paragraph.text.isupper():
style = title_style
elif paragraph.runs and paragraph.runs[0].bold:
style = title_style
else:
style = normal_style
p = Paragraph(paragraph.text, style)
story.append(p)
story.append(Spacer(1, 6))
if not story:
story.append(Paragraph("DOCX converted", normal_style))
# Costruisci il PDF
doc.build(story)
@staticmethod
def convert_latex_to_pdf(latex_content: bytes, original_filename: str) -> Tuple[bytes, str]:
try:
# Decodes LaTeX
latex_text = latex_content.decode('utf-8', errors='ignore')
# Converts to HTML
html_content = FileConverter._latex_to_html_advanced(latex_text)
# Converts to PDF
pdf_bytes = FileConverter._html_to_pdf(html_content)
# Creates filename
new_filename = original_filename.replace('.tex', '.pdf').replace('.latex', '.pdf')
return pdf_bytes, new_filename
except Exception as e:
raise Exception(f"Impossibile to convert from LaTeX to PDF: {str(e)}")
@staticmethod
def _latex_to_html_advanced(latex_text: str) -> str:
html = latex_text
html = re.sub(r'\\documentclass(?:\[[^\]]*\])?\{[^}]*\}', '', html)
html = re.sub(r'\\usepackage(?:\[[^\]]*\])?\{[^}]*\}', '', html)
html = re.sub(r'\\begin\{document\}', '', html)
html = re.sub(r'\\end\{document\}', '', html)
html = re.sub(r'\\maketitle', '', html)
html = re.sub(r'\\title\{([^}]*)\}', r'<h1 class="title">\1</h1>', html)
html = re.sub(r'\\author\{([^}]*)\}', r'<p class="author"><strong>Autore:</strong> \1</p>', html)
html = re.sub(r'\\date\{([^}]*)\}', r'<p class="date"><strong>Data:</strong> \1</p>', html)
html = re.sub(r'\\section\*?\{([^}]*)\}', r'<h2>\1</h2>', html)
html = re.sub(r'\\subsection\*?\{([^}]*)\}', r'<h3>\1</h3>', html)
html = re.sub(r'\\subsubsection\*?\{([^}]*)\}', r'<h4>\1</h4>', html)
html = re.sub(r'\\paragraph\{([^}]*)\}', r'<h5>\1</h5>', html)
html = re.sub(r'\\textbf\{([^}]*)\}', r'<strong>\1</strong>', html)
html = re.sub(r'\\textit\{([^}]*)\}', r'<em>\1</em>', html)
html = re.sub(r'\\emph\{([^}]*)\}', r'<em>\1</em>', html)
html = re.sub(r'\\underline\{([^}]*)\}', r'<u>\1</u>', html)
html = re.sub(r'\\texttt\{([^}]*)\}', r'<code>\1</code>', html)
html = re.sub(r'\$\$([^$]+)\$\$', r'<div class="math-block">\1</div>', html)
html = re.sub(r'\$([^$]+)\$', r'<span class="math-inline">\1</span>', html)
html = re.sub(r'\\begin\{itemize\}', '<ul>', html)
html = re.sub(r'\\end\{itemize\}', '</ul>', html)
html = re.sub(r'\\begin\{enumerate\}', '<ol>', html)
html = re.sub(r'\\end\{enumerate\}', '</ol>', html)
html = re.sub(r'\\item(?:\[[^\]]*\])?\s*', '<li>', html)
html = re.sub(r'\\begin\{quote\}', '<blockquote>', html)
html = re.sub(r'\\end\{quote\}', '</blockquote>', html)
html = re.sub(r'\\begin\{figure\}.*?\\end\{figure\}', '<div class="figure">[Figura]</div>', html,
flags=re.DOTALL)
html = re.sub(r'\\begin\{table\}.*?\\end\{table\}', '<div class="table">[Tabella]</div>', html, flags=re.DOTALL)
html = re.sub(r'\\[a-zA-Z]+(?:\[[^\]]*\])?\{[^}]*\}', '', html)
html = re.sub(r'\\[a-zA-Z]+', '', html)
html = re.sub(r'\\\\', '<br>', html)
html = re.sub(r'\n\s*\n', '</p><p>', html)
html = re.sub(r'\s+', ' ', html)
html = html.strip()
return FileConverter._wrap_html_with_styles(html, "LaTeX Document")
@staticmethod
def _wrap_html_with_styles(content: str, title: str) -> str:
html_template = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{title}</title>
<style>
@page {{
size: A4;
margin: 2cm;
}}
body {{
font-family: 'Times New Roman', serif;
font-size: 12pt;
line-height: 1.6;
text-align: justify;
color: #000;
}}
.title {{
font-size: 20pt;
font-weight: bold;
text-align: center;
margin-bottom: 16pt;
}}
.author, .date {{
text-align: center;
margin-bottom: 12pt;
font-style: italic;
}}
h1, h2 {{
font-size: 16pt;
font-weight: bold;
margin-top: 20pt;
margin-bottom: 12pt;
}}
h3 {{
font-size: 14pt;
font-weight: bold;
margin-top: 16pt;
margin-bottom: 10pt;
}}
h4, h5 {{
font-size: 12pt;
font-weight: bold;
margin-top: 12pt;
margin-bottom: 8pt;
}}
p {{
margin-bottom: 12pt;
text-indent: 0;
}}
ul, ol {{
margin-bottom: 12pt;
padding-left: 30pt;
}}
li {{
margin-bottom: 6pt;
}}
blockquote {{
margin: 12pt 20pt;
padding: 8pt;
border-left: 3pt solid #ccc;
font-style: italic;
}}
code {{
font-family: 'Courier New', monospace;
background-color: #f5f5f5;
padding: 2pt;
}}
.math-block {{
text-align: center;
margin: 12pt 0;
font-family: 'Times New Roman', serif;
}}
.math-inline {{
font-family: 'Times New Roman', serif;
}}
.figure, .table {{
text-align: center;
margin: 20pt 0;
padding: 10pt;
border: 1pt solid #ccc;
background-color: #f9f9f9;
}}
strong {{ font-weight: bold; }}
em {{ font-style: italic; }}
u {{ text-decoration: underline; }}
</style>
</head>
<body>
<div>{content}</div>
</body>
</html>
"""
return html_template
@staticmethod
def _html_to_pdf(html_content: str) -> bytes:
try:
# Creates PDF
html_doc = HTML(string=html_content)
pdf_bytes = html_doc.write_pdf()
return pdf_bytes
except Exception as e:
raise Exception(f"Impossible to convert from HTML to PDF: {str(e)}")
@staticmethod
def convert_to_pdf_if_needed(file_content: bytes, filename: str) -> Tuple[bytes, str]:
extension = FileConverter.get_file_extension(filename)
if extension == '.pdf':
return file_content, filename
elif extension == '.docx':
return FileConverter.convert_docx_to_pdf_mammoth(file_content, filename)
elif extension in ['.tex', '.latex']:
return FileConverter.convert_latex_to_pdf(file_content, filename)
else:
raise Exception(f"Format not supported: {extension}")
class AdvancedDocxConverter:
@staticmethod
def convert_docx_with_pandoc(docx_content: bytes, original_filename: str) -> Tuple[bytes, str]:
try:
import pypandoc
with tempfile.TemporaryDirectory() as temp_dir:
# Saves DOCX
docx_path = os.path.join(temp_dir, "temp.docx")
with open(docx_path, "wb") as f:
f.write(docx_content)
# Converts to HTML
html_content = pypandoc.convert_file(docx_path, 'html')
full_html = FileConverter._wrap_html_with_styles(html_content, "DOCX Document")
# Converts HTML to PDF
pdf_bytes = FileConverter._html_to_pdf(full_html)
new_filename = original_filename.replace('.docx', '.pdf')
return pdf_bytes, new_filename
except ImportError:
logger.warning("pypandoc not found for DOCX")
return FileConverter.convert_docx_to_pdf_mammoth(docx_content, original_filename)
except Exception as e:
logger.warning(f"pandoc error in DOCX: {e}, fallback to standard converter")
return FileConverter.convert_docx_to_pdf_mammoth(docx_content, original_filename)

View file

@ -0,0 +1,233 @@
from fastapi import Depends, APIRouter, Query, HTTPException
from sqlalchemy import select, or_, and_, asc, desc
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from app.db import Publication, get_db, Keyword, Author, User
from app.schemas import PublicationOut, UserPublicationOut
from app.users import current_active_user
# Create router for publication endpoints
router = APIRouter()
# Endpoint to delete a publication
@router.delete("/publications/{publication_id}")
async def delete_publication(
publication_id: int,
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Delete a publication owned by the current user
"""
# Find the publication with relations
result = await db.execute(
select(Publication).options(
selectinload(Publication.authors),
selectinload(Publication.keywords)
).where(
and_(
Publication.id == publication_id,
Publication.user_id == user.id # Security: only user's own publications
)
)
)
publication = result.scalar_one_or_none()
if not publication:
raise HTTPException(
status_code=404,
detail="Publication not found or you don't have permission to delete it"
)
publication_title = publication.title
# Delete the publication (many-to-many relations are deleted automatically)
await db.delete(publication)
await db.commit()
print(f"🗑️ Publication deleted: '{publication_title}' (ID: {publication_id}) by user {user.email}")
return {"message": f"Publication '{publication_title}' successfully deleted"}
# Endpoint for user publications with sorting
@router.get("/users/me/publications", response_model=List[UserPublicationOut])
async def get_user_publications(
order_by: Optional[str] = Query("date_desc",
description="Sort by: date_asc, date_desc, title_asc, title_desc"),
user: User = Depends(current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Returns all publications uploaded by the current user with sorting
"""
stmt = select(Publication).options(
selectinload(Publication.authors),
selectinload(Publication.keywords)
).where(
Publication.user_id == user.id
)
# Sorting management
if order_by == "date_asc":
stmt = stmt.order_by(asc(Publication.upload_date))
elif order_by == "date_desc":
stmt = stmt.order_by(desc(Publication.upload_date))
elif order_by == "title_asc":
stmt = stmt.order_by(asc(Publication.title))
elif order_by == "title_desc":
stmt = stmt.order_by(desc(Publication.title))
else:
# Default: descending by date (most recent first)
stmt = stmt.order_by(desc(Publication.upload_date))
result = await db.execute(stmt)
publications = result.scalars().all()
print(f"🔍 User {user.email} (ID: {user.id}) has {len(publications)} publications (sorted by: {order_by})")
return publications
# Search publications endpoint
@router.get("/publications", response_model=List[PublicationOut])
async def get_publications(
search: Optional[str] = Query(None,
description="Search by title, author or keyword. For multiple keywords use spaces: 'keyword1 keyword2'"),
order_by: Optional[str] = Query("date_desc",
description="Sort by: date_asc, date_desc, title_asc, title_desc"),
db: AsyncSession = Depends(get_db)
):
"""
🔍 ADVANCED SEARCH SYSTEM WITH KEYWORDS
Search function with priority and sorting:
1. Keywords (highest priority) - supports multiple search with spaces
2. Authors (medium priority)
3. Title (lowest priority)
Keywords are the core of the search system!
"""
print(f"🔍 Search: '{search}' | Sort by: {order_by}")
# If no search query, return all sorted
if search is None or not search.strip():
stmt = select(Publication).options(
selectinload(Publication.authors),
selectinload(Publication.keywords)
)
# Sorting management
if order_by == "date_asc":
stmt = stmt.order_by(asc(Publication.upload_date))
elif order_by == "date_desc":
stmt = stmt.order_by(desc(Publication.upload_date))
elif order_by == "title_asc":
stmt = stmt.order_by(asc(Publication.title))
elif order_by == "title_desc":
stmt = stmt.order_by(desc(Publication.title))
else:
# Default: descending by date
stmt = stmt.order_by(desc(Publication.upload_date))
result = await db.execute(stmt)
return result.scalars().all()
search_term = search.strip()
# Split search string into individual keywords
search_keywords = [kw.strip().lower() for kw in search_term.split() if kw.strip()]
print(f"🔍 Keywords to search: {search_keywords}")
# SET to track already found IDs
found_publication_ids = set()
final_results = []
# 🎯 1. SEARCH BY KEYWORDS (highest priority) - MULTIPLE SEARCH
if search_keywords:
print("🔍 Step 1: Searching by multiple keywords...")
# Create conditions for each keyword
keyword_conditions = []
for keyword in search_keywords:
keyword_pattern = f"%{keyword}%"
keyword_conditions.append(
Publication.keywords.any(Keyword.name.ilike(keyword_pattern))
)
# Publication must have ALL keywords (AND)
keyword_query = select(Publication).options(
selectinload(Publication.authors),
selectinload(Publication.keywords)
).where(
and_(*keyword_conditions) # All conditions must be true
)
keyword_result = await db.execute(keyword_query)
keyword_publications = keyword_result.scalars().all()
for pub in keyword_publications:
if pub.id not in found_publication_ids:
final_results.append(pub)
found_publication_ids.add(pub.id)
pub_keywords = [k.name for k in pub.keywords]
print(f" ✅ Found by keywords: {pub.title} (keywords: {pub_keywords})")
# 📝 2. SEARCH BY AUTHORS (medium priority) - uses complete string
print("🔍 Step 2: Searching by authors...")
author_pattern = f"%{search_term}%"
author_query = select(Publication).options(
selectinload(Publication.authors),
selectinload(Publication.keywords)
).join(Publication.authors).where(
Author.name.ilike(author_pattern)
)
author_result = await db.execute(author_query)
author_publications = author_result.scalars().all()
for pub in author_publications:
if pub.id not in found_publication_ids:
final_results.append(pub)
found_publication_ids.add(pub.id)
pub_authors = [a.name for a in pub.authors]
print(f" ✅ Found by author: {pub.title} (authors: {pub_authors})")
# 📰 3. SEARCH BY TITLE (lowest priority) - uses complete string
print("🔍 Step 3: Searching by title...")
title_pattern = f"%{search_term}%"
title_query = select(Publication).options(
selectinload(Publication.authors),
selectinload(Publication.keywords)
).where(
Publication.title.ilike(title_pattern)
)
title_result = await db.execute(title_query)
title_publications = title_result.scalars().all()
for pub in title_publications:
if pub.id not in found_publication_ids:
final_results.append(pub)
found_publication_ids.add(pub.id)
print(f" ✅ Found by title: {pub.title}")
# Apply sorting to final results
print(f"🔍 Applying sorting: {order_by}")
if order_by == "date_asc":
final_results.sort(key=lambda x: x.upload_date)
elif order_by == "date_desc":
final_results.sort(key=lambda x: x.upload_date, reverse=True)
elif order_by == "title_asc":
final_results.sort(key=lambda x: x.title.lower())
elif order_by == "title_desc":
final_results.sort(key=lambda x: x.title.lower(), reverse=True)
else:
# Default: descending by date
final_results.sort(key=lambda x: x.upload_date, reverse=True)
print(f"🔍 Total results found: {len(final_results)}")
return final_results

69
backend/app/schemas.py Normal file
View file

@ -0,0 +1,69 @@
import uuid
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
first_name: Optional[str] = None
last_name: Optional[str] = None
class UserCreate(schemas.BaseUserCreate):
first_name: Optional[str] = None
last_name: Optional[str] = None
class UserUpdate(schemas.BaseUserUpdate):
first_name: Optional[str] = None
last_name: Optional[str] = None
class AuthorOut(BaseModel):
id: int
name: str
class Config:
orm_mode = True
class KeywordOut(BaseModel):
id: int
name: str
class Config:
orm_mode = True
class PublicationOut(BaseModel):
id: int
title: str
filename: Optional[str]
upload_date: datetime
journal: Optional[str] = None
year: Optional[int] = None
doi: Optional[str] = None
authors: List[AuthorOut]
keywords: List[KeywordOut]
user_id: Optional[uuid.UUID] = None
class Config:
orm_mode = True
class UserPublicationOut(BaseModel):
id: int
title: str
filename: Optional[str]
upload_date: datetime
journal: Optional[str] = None
year: Optional[int] = None
doi: Optional[str] = None
authors: List[AuthorOut]
keywords: List[KeywordOut]
class Config:
orm_mode = True

253
backend/app/upload.py Normal file
View file

@ -0,0 +1,253 @@
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import joinedload
from app.db import Publication, Author, Keyword, User, get_db
from app.utils import parser, nlp
from app.users import current_active_user
from app.file_converter import FileConverter, AdvancedDocxConverter
from typing import Optional
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/upload/")
async def upload_publication(
file: UploadFile = File(...),
bibtex: Optional[UploadFile] = File(None),
title: Optional[str] = Form(None),
authors: Optional[str] = Form(None),
year: Optional[int] = Form(None),
journal: Optional[str] = Form(None),
doi: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
user: User = Depends(current_active_user)
):
try:
bibtex_metadata = None
if bibtex is not None:
try:
bibtex_content = (await bibtex.read()).decode("utf-8")
b_title, b_authors, b_year, b_journal, b_doi = parser.bibtex(bibtex_content)
bibtex_metadata = {
"title": b_title,
"authors": b_authors,
"year": b_year,
"journal": b_journal,
"doi": b_doi
}
title = title or b_title
authors = authors or b_authors
year = year or b_year
journal = journal or b_journal
doi = doi or b_doi
logger.info(f"BibTeX processed. Metadatas extracted: {bibtex_metadata}")
except Exception as e:
logger.error(f"Parsing BibTeX error: {e}")
raise HTTPException(
status_code=400,
detail=f"Parsing BibTeX error: {str(e)}"
)
if doi and not is_valid_doi(doi):
raise HTTPException(
status_code=400,
detail="DOI invalid. Use this format: 10.xxxx/xxxxx"
)
if doi:
existing_doi = await db.execute(
select(Publication).where(Publication.doi == doi)
)
if existing_doi.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail="DOI existing"
)
if bibtex is None:
missing_fields = []
if not title: missing_fields.append("title")
if not authors: missing_fields.append("authors")
if not year: missing_fields.append("year")
if not journal: missing_fields.append("journal")
if missing_fields:
raise HTTPException(
status_code=400,
detail=f"Missing fields: {', '.join(missing_fields)}. "
f"Insert fields or upload a BibTeX."
)
logger.info("Manual mode")
else:
if not all([title, authors, year, journal]):
missing_from_bibtex = []
if not title: missing_from_bibtex.append("title")
if not authors: missing_from_bibtex.append("authors")
if not year: missing_from_bibtex.append("year")
if not journal: missing_from_bibtex.append("journal")
logger.error(f"Missing from BibTeX: {missing_from_bibtex}")
raise HTTPException(
status_code=400,
detail=f"Missing fields: {', '.join(missing_from_bibtex)}. "
)
logger.info("BibTeX mode")
if not file:
raise HTTPException(status_code=400, detail="File needed")
allowed_extensions = ['.pdf', '.docx', '.tex', '.latex']
file_extension = '.' + file.filename.split('.')[-1].lower() if '.' in file.filename else ''
if file_extension not in allowed_extensions:
logger.error(f"Extension not allowed: {file_extension}")
raise HTTPException(
status_code=400,
detail=f"Extension not allowed, please upload these: {', '.join(allowed_extensions)}"
)
content = await file.read()
logger.info(f"File uploaded: {file.filename} ({len(content)} bytes)")
try:
file_ext = FileConverter.get_file_extension(file.filename)
conversion_method = "none"
if file_ext == '.docx':
try:
converted_content, final_filename = AdvancedDocxConverter.convert_docx_with_pandoc(
content, file.filename
)
conversion_method = "pandoc"
logger.info(f"DOCX converted pandoc: {file.filename} -> {final_filename}")
except Exception as pandoc_error:
logger.warning(f"Pandoc failed with DOCX: {pandoc_error}, use mammoth")
converted_content, final_filename = FileConverter.convert_to_pdf_if_needed(
content, file.filename
)
conversion_method = "mammoth"
logger.info(f"DOCX converted with mammoth: {file.filename} -> {final_filename}")
else:
converted_content, final_filename = FileConverter.convert_to_pdf_if_needed(
content, file.filename
)
conversion_method = "standard" if file_ext in ['.tex', '.latex'] else "none"
logger.info(f"File processed: {file.filename} -> {final_filename}")
except Exception as e:
logger.error(f"Error while converting the file: {e}")
raise HTTPException(
status_code=500,
detail=f"Error while converting the file: {str(e)}"
)
try:
text = parser.extract_text(file.filename, content)
keywords = nlp.extract_keywords(text)
logger.info(f"{len(keywords)} keywords extracted")
except Exception as e:
logger.warning(f"Error while extracting keywords: {e}")
keywords = []
author_names = [a.strip() for a in authors.split(",") if a.strip()]
keyword_names = [k.strip().lower() for k in keywords if k.strip()]
logger.info(f"Authors to process: {author_names}")
logger.info(f"Keywords to process: {keyword_names}")
author_objs = []
for name in author_names:
result = await db.execute(select(Author).where(Author.name == name))
author = result.scalar_one_or_none()
if not author:
author = Author(name=name)
db.add(author)
await db.flush()
logger.info(f"New author created: {name}")
else:
logger.info(f"Existing author found: {name}")
author_objs.append(author)
keyword_objs = []
for kw in keyword_names:
result = await db.execute(select(Keyword).where(Keyword.name == kw))
keyword = result.scalar_one_or_none()
if not keyword:
keyword = Keyword(name=kw)
db.add(keyword)
await db.flush()
logger.info(f"Keyword created: {kw}")
else:
logger.info(f"Existing keyword found: {kw}")
keyword_objs.append(keyword)
publication = Publication(
title=title,
file=converted_content,
filename=final_filename,
journal=journal,
year=year,
doi=doi,
user_id=user.id,
authors=author_objs,
keywords=keyword_objs
)
db.add(publication)
await db.commit()
await db.refresh(publication)
result = await db.execute(
select(Publication)
.options(joinedload(Publication.authors), joinedload(Publication.keywords))
.where(Publication.id == publication.id)
)
publication_with_rel = result.unique().scalar_one()
author_names_response = [author.name for author in publication_with_rel.authors]
keyword_names_response = [kw.name for kw in publication_with_rel.keywords]
response_data = {
"id": publication_with_rel.id,
"title": publication_with_rel.title,
"authors": author_names_response,
"keywords": keyword_names_response,
"journal": publication_with_rel.journal,
"year": publication_with_rel.year,
"doi": publication_with_rel.doi,
"original_filename": file.filename,
"converted_filename": final_filename,
"conversion_method": conversion_method
}
if bibtex is not None:
response_data["metadata_source"] = "bibtex"
response_data["bibtex_data"] = bibtex_metadata
logger.info("Saved with BibTeX metadata")
else:
response_data["metadata_source"] = "manual"
logger.info("Saved with classical metadata")
return response_data
except HTTPException:
raise
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=500,
detail=f"Upload error: {str(e)}"
)
def is_valid_doi(doi: str) -> bool:
import re
doi_pattern = r'^10\.\d{4,}/[-._;()/:\w\[\]]+$'
return bool(re.match(doi_pattern, doi, re.IGNORECASE))

56
backend/app/users.py Normal file
View file

@ -0,0 +1,56 @@
import uuid
from typing import Optional
from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
JWTStrategy,
)
from fastapi_users.db import SQLAlchemyUserDatabase
from app.db import User, get_user_db
#CHANGE ME
SECRET = "1d90d4315c0a0313fb65211fa82e88129cddedb8b662553fbd38f44be9dc818bbd8623ca0177d965e762ee9727b5f6a2bd98481311ecccbcae846bff4f57b8ce72a51fca3278caa05ff18e54c563788d2a67b44be6fc667c12d1b6c2d869f6637b67025a6aa938e811616f27c160a13dc7b653e56a9823f61a165cdf671f734c"
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = SECRET
verification_token_secret = SECRET
async def on_after_register(self, user: User, request: Optional[Request] = None):
print(f"User {user.id} has registered.")
async def on_after_forgot_password(
self, user: User, token: str, request: Optional[Request] = None
):
print(f"User {user.id} has forgot their password. Reset token: {token}")
async def on_after_request_verify(
self, user: User, token: str, request: Optional[Request] = None
):
print(f"Verification requested for user {user.id}. Verification token: {token}")
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy[models.UP, models.ID]:
return JWTStrategy(secret=SECRET, lifetime_seconds=3600)
auth_backend = AuthenticationBackend(
name="jwt",
transport=bearer_transport,
get_strategy=get_jwt_strategy,
)
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
current_active_user = fastapi_users.current_user(active=True)

6
backend/app/utils/nlp.py Normal file
View file

@ -0,0 +1,6 @@
import yake
def extract_keywords(text: str, num_keywords: int = 5) -> list:
kw_extractor = yake.KeywordExtractor(lan="en", n=1, top=num_keywords)
keywords = kw_extractor.extract_keywords(text)
return [kw for kw, _ in keywords]

165
backend/app/utils/parser.py Normal file
View file

@ -0,0 +1,165 @@
import bibtexparser
import io
import logging
from typing import Tuple, Optional
from pdfminer.high_level import extract_text as pdf_extract_text
from pdfminer.high_level import extract_text_to_fp
import tempfile
import os
logger = logging.getLogger(__name__)
def bibtex(bibtex_content: str) -> Tuple[Optional[str], Optional[str], Optional[int], Optional[str], Optional[str]]:
"""
Estrae title, authors, year, journal, doi dal primo record di un file bibtex.
Ritorna una tupla (title, authors, year, journal, doi).
"""
bib_database = bibtexparser.load(io.StringIO(bibtex_content))
if not bib_database.entries:
return (None, None, None, None, None)
entry = bib_database.entries[0]
title = entry.get('title')
authors = entry.get('authors') # o 'author' se il campo è diverso
year = int(entry['year']) if 'year' in entry else None
journal = entry.get('journal')
doi = entry.get('doi')
return (title, authors, year, journal, doi)
def extract_text(filename: str, content: bytes) -> str:
"""
🎯 FUNZIONE FONDAMENTALE: Estrae testo da file PDF per l'analisi delle keywords
Args:
filename: Nome del file (per determinare il tipo)
content: Contenuto del file in bytes
Returns:
str: Testo estratto dal documento
"""
try:
# Determina l'estensione del file
file_extension = os.path.splitext(filename.lower())[1]
if file_extension == '.pdf':
return extract_text_from_pdf(content)
elif file_extension == '.docx':
return extract_text_from_docx(content)
elif file_extension in ['.tex', '.latex']:
return extract_text_from_latex(content)
else:
logger.warning(f"Tipo di file non supportato per estrazione testo: {file_extension}")
return ""
except Exception as e:
logger.error(f"Errore nell'estrazione del testo da {filename}: {e}")
return ""
def extract_text_from_pdf(pdf_content: bytes) -> str:
"""
Estrae testo da contenuto PDF usando pdfminer
"""
try:
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temp_file:
temp_file.write(pdf_content)
temp_file.flush()
# Estrae il testo usando pdfminer
text = pdf_extract_text(temp_file.name)
# Pulisce il file temporaneo
os.unlink(temp_file.name)
logger.info(f"Estratto testo PDF: {len(text)} caratteri")
return text or ""
except Exception as e:
logger.error(f"Errore nell'estrazione testo da PDF: {e}")
return ""
def extract_text_from_docx(docx_content: bytes) -> str:
"""
Estrae testo da contenuto DOCX usando python-docx
"""
try:
from docx import Document
with tempfile.NamedTemporaryFile(suffix='.docx', delete=False) as temp_file:
temp_file.write(docx_content)
temp_file.flush()
# Estrae il testo usando python-docx
doc = Document(temp_file.name)
text_parts = []
for paragraph in doc.paragraphs:
text_parts.append(paragraph.text)
text = '\n'.join(text_parts)
# Pulisce il file temporaneo
os.unlink(temp_file.name)
logger.info(f"Estratto testo DOCX: {len(text)} caratteri")
return text
except Exception as e:
logger.error(f"Errore nell'estrazione testo da DOCX: {e}")
return ""
def extract_text_from_latex(latex_content: bytes) -> str:
"""
Estrae testo da contenuto LaTeX rimuovendo i comandi LaTeX
"""
try:
from pylatexenc.latex2text import LatexNodes2Text
# Decodifica il contenuto
latex_text = latex_content.decode('utf-8', errors='ignore')
# Converte LaTeX in testo semplice
converter = LatexNodes2Text()
text = converter.latex_to_text(latex_text)
logger.info(f"Estratto testo LaTeX: {len(text)} caratteri")
return text
except Exception as e:
logger.error(f"Errore nell'estrazione testo da LaTeX: {e}")
# Fallback: rimuovi manualmente i comandi LaTeX più comuni
try:
latex_text = latex_content.decode('utf-8', errors='ignore')
# Rimuove comandi LaTeX di base
import re
text = re.sub(r'\\[a-zA-Z]+\{[^}]*\}', '', latex_text)
text = re.sub(r'\\[a-zA-Z]+', '', text)
text = re.sub(r'\{[^}]*\}', '', text)
text = re.sub(r'%.*', '', text) # Rimuove commenti
return text.strip()
except:
return ""
def clean_extracted_text(text: str) -> str:
"""
Pulisce il testo estratto per migliorare l'estrazione delle keywords
"""
import re
# Rimuove caratteri di controllo e spazi multipli
text = re.sub(r'\s+', ' ', text)
# Rimuove caratteri speciali eccessivi
text = re.sub(r'[^\w\s\-.,;:()[\]{}]', ' ', text)
# Rimuove linee molto corte (probabilmente header/footer)
lines = text.split('\n')
clean_lines = [line.strip() for line in lines if len(line.strip()) > 10]
return '\n'.join(clean_lines).strip()

View file

@ -0,0 +1,14 @@
services:
db:
container_name: pg
image: postgres:15-alpine
env_file:
- ./.env
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

9
backend/main.py Normal file
View file

@ -0,0 +1,9 @@
from dotenv import load_dotenv
import os
load_dotenv()
#from database import save_publication
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.app:app", host="0.0.0.0", log_level="info")

34
backend/requirements.txt Normal file
View file

@ -0,0 +1,34 @@
#backend core
fastapi[all]
fastapi-asyncpg
fastapi-users[sqlalchemy,postgresql]
#database drivers
asyncpg
#parser
python-multipart
#web server
uvicorn
#utils
pdfminer.six
python-docx
pylatexenc
bibtexparser
#NLP
yake
#file conversion
python-docx
reportlab
weasyprint
markdown
mammoth
pypandoc-binary
boto3
python-dotenv

41
docker-compose.yml Normal file
View file

@ -0,0 +1,41 @@
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: scientify_db
POSTGRES_USER: scientify_user
POSTGRES_PASSWORD: scientify_pass
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U scientify_user -d scientify"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- ./backend/.env
depends_on:
db:
condition: service_healthy
volumes:
- ./backend:/app
command: python main.py
frontend:
build: ./frontend
environment:
- ./.env.production
ports:
- "80:80"
depends_on:
- backend
volumes:
postgres_data:

1
frontend/.env.production Normal file
View file

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

28
frontend/Dockerfile Normal file
View file

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

30
frontend/nginx.conf Normal file
View file

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

54
frontend/package.json Normal file
View file

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

12629
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

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

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

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

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

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

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

View file

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

View file

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

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

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