mirror of
https://github.com/fccapria/scientify.git
synced 2026-01-12 02:36:10 +00:00
Initial release
This commit is contained in:
commit
ae5e4b8873
52 changed files with 17572 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
*~
|
||||||
|
backend/.venv/
|
||||||
|
|
||||||
|
*/__pycache__/
|
||||||
|
*/*/__pycache__/
|
||||||
|
*/*/*/__pycache__/
|
||||||
|
|
||||||
|
frontend/build
|
||||||
|
frontend/node_modules
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
167
README.md
Normal 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
8
backend/.env
Normal 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
25
backend/Dockerfile
Normal 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
82
backend/app/app.py
Normal 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
100
backend/app/db.py
Normal 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')
|
||||||
59
backend/app/debug_routes.py
Normal file
59
backend/app/debug_routes.py
Normal 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
26
backend/app/download.py
Normal 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}"}
|
||||||
|
)
|
||||||
362
backend/app/file_converter.py
Normal file
362
backend/app/file_converter.py
Normal 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)
|
||||||
233
backend/app/publication_routes.py
Normal file
233
backend/app/publication_routes.py
Normal 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
69
backend/app/schemas.py
Normal 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
253
backend/app/upload.py
Normal 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
56
backend/app/users.py
Normal 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
6
backend/app/utils/nlp.py
Normal 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
165
backend/app/utils/parser.py
Normal 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()
|
||||||
14
backend/docker-compose.yml
Normal file
14
backend/docker-compose.yml
Normal 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
9
backend/main.py
Normal 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
34
backend/requirements.txt
Normal 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
41
docker-compose.yml
Normal 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
1
frontend/.env.production
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
REACT_APP_API_URL=/api
|
||||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Build stage
|
||||||
|
FROM node:18-alpine as build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV GENERATE_SOURCEMAP=false
|
||||||
|
ENV ESLINT_NO_DEV_ERRORS=true
|
||||||
|
ENV DISABLE_ESLINT_PLUGIN=true
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
30
frontend/nginx.conf
Normal file
30
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 300;
|
||||||
|
proxy_send_timeout 300;
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
send_timeout 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/package.json
Normal file
54
frontend/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"name": "scientify-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Scientify frontend - modern platform for managing and sharing scientific publications",
|
||||||
|
"author": "Francesco Carmelo Capria",
|
||||||
|
"homepage": ".",
|
||||||
|
"private": true,
|
||||||
|
"keywords": [
|
||||||
|
"scientif publications",
|
||||||
|
"research"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@mui/icons-material": "^7.1.0",
|
||||||
|
"@mui/material": "^7.1.0",
|
||||||
|
"@tanstack/react-query": "^5.76.1",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
|
"analyze": "npm run build && npx serve -s build"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
12629
frontend/pnpm-lock.yaml
generated
Normal file
12629
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
37
frontend/public/favicon.svg
Normal file
37
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Sfondo circolare -->
|
||||||
|
<circle cx="32" cy="32" r="30" fill="url(#gradient)" stroke="#ffffff" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Microscopio base -->
|
||||||
|
<rect x="26" y="48" width="12" height="8" fill="#ffffff" rx="2"/>
|
||||||
|
|
||||||
|
<!-- Corpo del microscopio -->
|
||||||
|
<rect x="28" y="35" width="8" height="15" fill="#ffffff" rx="1"/>
|
||||||
|
|
||||||
|
<!-- Oculare -->
|
||||||
|
<circle cx="32" cy="20" r="4" fill="#ffffff"/>
|
||||||
|
<circle cx="32" cy="20" r="2.5" fill="#667eea"/>
|
||||||
|
|
||||||
|
<!-- Braccio laterale -->
|
||||||
|
<rect x="36" y="28" width="12" height="3" fill="#ffffff" rx="1.5"/>
|
||||||
|
<circle cx="48" cy="29.5" r="3" fill="#ffffff"/>
|
||||||
|
<circle cx="48" cy="29.5" r="1.5" fill="#667eea"/>
|
||||||
|
|
||||||
|
<!-- Obiettivo -->
|
||||||
|
<rect x="30" y="40" width="4" height="6" fill="#667eea" rx="1"/>
|
||||||
|
|
||||||
|
<!-- Piattaforma campione -->
|
||||||
|
<rect x="24" y="42" width="16" height="2" fill="#ffffff" rx="1"/>
|
||||||
|
|
||||||
|
<!-- Dettagli decorativi -->
|
||||||
|
<circle cx="20" cy="15" r="1.5" fill="#ffffff" opacity="0.8"/>
|
||||||
|
<circle cx="44" cy="18" r="1" fill="#ffffff" opacity="0.6"/>
|
||||||
|
<circle cx="18" cy="35" r="1" fill="#ffffff" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
36
frontend/public/index.html
Normal file
36
frontend/public/index.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#667eea" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Scientify - modern platform for managing and sharing scientific publications"
|
||||||
|
/>
|
||||||
|
<meta name="keywords" content="" />
|
||||||
|
<meta name="author" content="Scientify Team" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Scientify - Scientific Publications Management" />
|
||||||
|
<meta property="og:description" content="modern platform for managing and sharing scientific publications" />
|
||||||
|
<meta property="og:image" content="%PUBLIC_URL%/favicon.svg" />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:title" content="Scientify - Scientific Publications Management" />
|
||||||
|
<meta property="twitter:description" content="modern platform for managing and sharing scientific publications" />
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.svg" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
|
||||||
|
<title>Scientify - Scientific Publications Management</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>Allow javascript to use Scientify.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
22
frontend/public/manifest.json
Normal file
22
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"short_name": "Scientify",
|
||||||
|
"name": "Scientify - Scientific Publications Management",
|
||||||
|
"description": "Modern platform for managing and sharing scientific publications",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#667eea",
|
||||||
|
"background_color": "#f8fafc",
|
||||||
|
"categories": ["education", "productivity", "utilities"]
|
||||||
|
}
|
||||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
30
frontend/src/App.js
Normal file
30
frontend/src/App.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
import { CustomThemeProvider } from "./context/ThemeContext";
|
||||||
|
import AppBarHeader from "./components/AppBarHeader";
|
||||||
|
import HomePage from "./pages/HomePage";
|
||||||
|
import UserPublicationsPage from "./pages/UserPublicationsPage";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||||
|
<AppBarHeader onUploadClick={() => setUploadOpen(true)} />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/me" element={<UserPublicationsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Box>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</CustomThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
67
frontend/src/api/auth.js
Normal file
67
frontend/src/api/auth.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { API_URL } from "./client";
|
||||||
|
|
||||||
|
export async function login({ email, password }) {
|
||||||
|
const res = await fetch(`${API_URL}/auth/jwt/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ username: email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Credenziali non valide");
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem("access_token", data.access_token);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register({ email, password, first_name, last_name }) {
|
||||||
|
const payload = {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
is_verified: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggiungi nome e cognome solo se forniti
|
||||||
|
if (first_name) payload.first_name = first_name;
|
||||||
|
if (last_name) payload.last_name = last_name;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
if (res.status === 400) {
|
||||||
|
throw new Error("Email già registrata o password non valida");
|
||||||
|
}
|
||||||
|
throw new Error(errorData.detail || "Errore durante la registrazione");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funzione per aggiornare il profilo utente
|
||||||
|
export async function updateUserProfile({ first_name, last_name, token }) {
|
||||||
|
const res = await fetch(`${API_URL}/users/me`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ first_name, last_name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Errore nell'aggiornamento del profilo");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
44
frontend/src/api/client.js
Normal file
44
frontend/src/api/client.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export const API_URL = process.env.REACT_APP_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
export function getAuthHeaders(token) {
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurazione centralizzata per le chiamate API
|
||||||
|
export const apiConfig = {
|
||||||
|
baseURL: API_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper per gestire errori API
|
||||||
|
export function handleApiError(error) {
|
||||||
|
if (error.response) {
|
||||||
|
// Server ha risposto con codice di errore
|
||||||
|
const status = error.response.status;
|
||||||
|
const message = error.response.data?.detail || error.response.data?.message || 'Errore del server';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
window.location.href = '/';
|
||||||
|
throw new Error('Sessione scaduta. Effettua nuovamente il login.');
|
||||||
|
case 403:
|
||||||
|
throw new Error('Non hai i permessi per questa operazione.');
|
||||||
|
case 404:
|
||||||
|
throw new Error('Risorsa non trovata.');
|
||||||
|
case 422:
|
||||||
|
throw new Error('Dati non validi: ' + message);
|
||||||
|
default:
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
// Nessuna risposta dal server
|
||||||
|
throw new Error('Impossibile contattare il server. Verifica la connessione.');
|
||||||
|
} else {
|
||||||
|
// Errore nella configurazione della richiesta
|
||||||
|
throw new Error('Errore di configurazione: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/src/api/config.js
Normal file
5
frontend/src/api/config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export const API_URL = config.API_URL;
|
||||||
|
|
||||||
|
// Il resto del file rimane invariato
|
||||||
54
frontend/src/api/publications.js
Normal file
54
frontend/src/api/publications.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { API_URL, getAuthHeaders } from "./client";
|
||||||
|
|
||||||
|
export async function fetchPublications({ queryKey }) {
|
||||||
|
const [_key, { search, orderBy, token }] = queryKey;
|
||||||
|
let url = `${API_URL}/publications?`;
|
||||||
|
if (search) url += `search=${encodeURIComponent(search)}&`;
|
||||||
|
if (orderBy) url += `order_by=${orderBy}&`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: getAuthHeaders(token),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Errore nel caricamento pubblicazioni");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPublication({ file, token }) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/publications`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(token),
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Errore durante l'upload");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserPublications({ queryKey }) {
|
||||||
|
const [_key, { token, orderBy }] = queryKey;
|
||||||
|
let url = `${API_URL}/users/me/publications`;
|
||||||
|
if (orderBy) url += `?order_by=${orderBy}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: getAuthHeaders(token),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Errore nel caricamento delle tue pubblicazioni");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NUOVA: Funzione per eliminare una pubblicazione
|
||||||
|
export async function deletePublication({ publicationId, token }) {
|
||||||
|
const res = await fetch(`${API_URL}/publications/${publicationId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: getAuthHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Errore durante l'eliminazione della pubblicazione");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
132
frontend/src/components/AppBarHeader.jsx
Normal file
132
frontend/src/components/AppBarHeader.jsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
IconButton,
|
||||||
|
Tooltip
|
||||||
|
} from "@mui/material";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import UserMenu from "./UserMenu";
|
||||||
|
import UploadModal from "./UploadModal";
|
||||||
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||||
|
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||||
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
|
|
||||||
|
export default function Header({ onUploadSuccess }) {
|
||||||
|
const [openUpload, setOpenUpload] = useState(false);
|
||||||
|
const { darkMode, toggleDarkMode } = useTheme();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleUploadSuccess = () => {
|
||||||
|
if (onUploadSuccess) {
|
||||||
|
onUploadSuccess();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Container maxWidth="lg" sx={{ pt: 2 }}>
|
||||||
|
<AppBar
|
||||||
|
position="static"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||||
|
background: darkMode
|
||||||
|
? 'linear-gradient(135deg, rgba(99, 102, 241, 0.9) 0%, rgba(139, 92, 246, 0.9) 100%)'
|
||||||
|
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
border: darkMode ? '1px solid rgba(255,255,255,0.1)' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ justifyContent: "space-between", py: 1 }}>
|
||||||
|
{/* Clickable logo */}
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
sx={{
|
||||||
|
textTransform: 'none',
|
||||||
|
padding: 0,
|
||||||
|
minWidth: 'auto',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
background: 'linear-gradient(45deg, #ffffff 30%, #f0f0f0 90%)',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔬 Scientify
|
||||||
|
</Typography>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{/* Toggle Dark Mode */}
|
||||||
|
<Tooltip title={darkMode ? "Light mode" : "Dark mode"}>
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
sx={{
|
||||||
|
color: 'white',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Upload Button */}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<CloudUploadIcon />}
|
||||||
|
onClick={() => setOpenUpload(true)}
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: '0 8px 25px rgba(0,0,0,0.3)'
|
||||||
|
},
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textTransform: 'none',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<UserMenu />
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
<UploadModal
|
||||||
|
open={openUpload}
|
||||||
|
onClose={() => setOpenUpload(false)}
|
||||||
|
onUploadSuccess={handleUploadSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/components/DeletePublicationDialog.jsx
Normal file
130
frontend/src/components/DeletePublicationDialog.jsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
IconButton
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
|
export default function DeletePublicationDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
publication,
|
||||||
|
onConfirmDelete,
|
||||||
|
loading = false
|
||||||
|
}) {
|
||||||
|
if (!publication) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{
|
||||||
|
pb: 1,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'error.50'
|
||||||
|
}}>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<WarningIcon color="error" />
|
||||||
|
<Typography variant="h6" fontWeight="bold" color="error.main">
|
||||||
|
Confirm Deletion
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={onClose} size="small">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent sx={{ py: 3 }}>
|
||||||
|
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
|
||||||
|
<Typography variant="body2" fontWeight="medium">
|
||||||
|
Warning: this action cannot be undone!
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
|
Are you sure you want to permanently delete this publication?
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" fontWeight="bold" color="primary.main">
|
||||||
|
{publication.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{publication.authors && publication.authors.length > 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Authors: {publication.authors.map(a => a.name).join(", ")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{publication.journal && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Published in: {publication.journal}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Uploaded on: {new Date(publication.upload_date).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ p: 3, gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outlined"
|
||||||
|
disabled={loading}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 100
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => onConfirmDelete(publication.id)}
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 120
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Deleting..." : "Delete Permanently"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
frontend/src/components/LoginDialog.jsx
Normal file
370
frontend/src/components/LoginDialog.jsx
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
Divider,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Chip
|
||||||
|
} from "@mui/material";
|
||||||
|
import { login, register } from "../api/auth";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import LoginIcon from '@mui/icons-material/Login';
|
||||||
|
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import EmailIcon from '@mui/icons-material/Email';
|
||||||
|
import { InputAdornment } from "@mui/material";
|
||||||
|
|
||||||
|
export default function LoginDialog({ open, onClose }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tabValue, setTabValue] = useState(0); // 0 = Login, 1 = Register
|
||||||
|
const { setToken } = useAuth();
|
||||||
|
|
||||||
|
const isLogin = tabValue === 0;
|
||||||
|
const isRegister = tabValue === 1;
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const token = await login({ email, password });
|
||||||
|
setToken(token);
|
||||||
|
setSuccess("Login successful!");
|
||||||
|
setTimeout(() => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
}, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Login failed. Check your email and password.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("Password must be at least 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName
|
||||||
|
});
|
||||||
|
setSuccess("🎉 Registration complete! You can now log in.");
|
||||||
|
setTimeout(() => {
|
||||||
|
setTabValue(0);
|
||||||
|
setPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setFirstName("");
|
||||||
|
setLastName("");
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Error during registration");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setFirstName("");
|
||||||
|
setLastName("");
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
setTabValue(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (event, newValue) => {
|
||||||
|
setTabValue(newValue);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{
|
||||||
|
pb: 1,
|
||||||
|
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider'
|
||||||
|
}}>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{isLogin ? <LoginIcon color="primary" /> : <PersonAddIcon color="primary" />}
|
||||||
|
<Typography variant="h6" fontWeight="bold">
|
||||||
|
Welcome to Scientify
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={handleClose} size="small">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs value={tabValue} onChange={handleTabChange} centered>
|
||||||
|
<Tab
|
||||||
|
label="Login"
|
||||||
|
icon={<LoginIcon />}
|
||||||
|
iconPosition="start"
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label="Register"
|
||||||
|
icon={<PersonAddIcon />}
|
||||||
|
iconPosition="start"
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DialogContent sx={{ py: 3 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box textAlign="center">
|
||||||
|
{isLogin ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Enter your credentials to access your account
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1} alignItems="center">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Create a new account to upload your publications
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label="Free • Fast • Secure"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* First and Last Name (only for registration) */}
|
||||||
|
{isRegister && (
|
||||||
|
<Box display="flex" gap={2}>
|
||||||
|
<TextField
|
||||||
|
label="First Name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={e => setFirstName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<PersonIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Last Name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={e => setLastName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<PersonIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
required
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<EmailIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
required
|
||||||
|
helperText={isRegister ? "Minimum 6 characters" : ""}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<LockIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isRegister && (
|
||||||
|
<TextField
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
required
|
||||||
|
error={confirmPassword && password !== confirmPassword}
|
||||||
|
helperText={confirmPassword && password !== confirmPassword ? "Passwords do not match" : ""}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<LockIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ borderRadius: 2 }}>
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<DialogActions sx={{ p: 3, gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
variant="outlined"
|
||||||
|
size="large"
|
||||||
|
sx={{ borderRadius: 2, textTransform: 'none', minWidth: 100 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isLogin ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleLogin}
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || !email || !password}
|
||||||
|
size="large"
|
||||||
|
startIcon={<LoginIcon />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 120,
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Logging in..." : "Login"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleRegister}
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || !email || !password || !confirmPassword || password !== confirmPassword}
|
||||||
|
size="large"
|
||||||
|
startIcon={<PersonAddIcon />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 120,
|
||||||
|
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Registering..." : "Register"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
379
frontend/src/components/PublicationList.jsx
Normal file
379
frontend/src/components/PublicationList.jsx
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchPublications } from "../api/publications";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Stack,
|
||||||
|
ButtonGroup
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useState } from "react";
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
|
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
|
||||||
|
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||||
|
import SortIcon from '@mui/icons-material/Sort';
|
||||||
|
import SortByAlphaIcon from '@mui/icons-material/SortByAlpha';
|
||||||
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
|
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export default function PublicationList({ search }) {
|
||||||
|
const [orderBy, setOrderBy] = useState("date_desc");
|
||||||
|
const { token } = useAuth();
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["publications", { search, orderBy, token }],
|
||||||
|
queryFn: fetchPublications,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to get current sort information
|
||||||
|
const getOrderInfo = () => {
|
||||||
|
switch(orderBy) {
|
||||||
|
case "date_asc": return { type: "date", direction: "asc", label: "Date ascending", icon: <ArrowUpwardIcon /> };
|
||||||
|
case "date_desc": return { type: "date", direction: "desc", label: "Date descending", icon: <ArrowDownwardIcon /> };
|
||||||
|
case "title_asc": return { type: "title", direction: "asc", label: "Title A-Z", icon: <ArrowUpwardIcon /> };
|
||||||
|
case "title_desc": return { type: "title", direction: "desc", label: "Title Z-A", icon: <ArrowDownwardIcon /> };
|
||||||
|
default: return { type: "date", direction: "desc", label: "Date descending", icon: <ArrowDownwardIcon /> };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to handle sort button clicks
|
||||||
|
const handleSort = (type) => {
|
||||||
|
const currentOrder = getOrderInfo();
|
||||||
|
|
||||||
|
if (currentOrder.type === type) {
|
||||||
|
// If same type, toggle direction
|
||||||
|
const newDirection = currentOrder.direction === "asc" ? "desc" : "asc";
|
||||||
|
setOrderBy(`${type}_${newDirection}`);
|
||||||
|
} else {
|
||||||
|
// If different type, set default direction
|
||||||
|
const defaultDirection = type === "date" ? "desc" : "asc";
|
||||||
|
setOrderBy(`${type}_${defaultDirection}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Box display="flex" justifyContent="center" mt={4}>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentOrder = getOrderInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
{/* Header with advanced sort controls */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{data?.length || 0} publications found
|
||||||
|
{search && (
|
||||||
|
<Typography component="span" variant="body2" color="primary.main" sx={{ ml: 1, fontWeight: 'bold' }}>
|
||||||
|
for "{search}"
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Sort controls */}
|
||||||
|
{data?.length > 1 && (
|
||||||
|
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<SortIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Sort by:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ButtonGroup variant="outlined" size="small">
|
||||||
|
{/* Date button */}
|
||||||
|
<Button
|
||||||
|
variant={currentOrder.type === "date" ? "contained" : "outlined"}
|
||||||
|
onClick={() => handleSort("date")}
|
||||||
|
startIcon={currentOrder.type === "date" ? currentOrder.icon : <CalendarTodayIcon />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '8px 0 0 8px',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: currentOrder.type === "date" ? 'bold' : 'normal',
|
||||||
|
minWidth: '100px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentOrder.type === "date" ? currentOrder.label : "Date"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Title button */}
|
||||||
|
<Button
|
||||||
|
variant={currentOrder.type === "title" ? "contained" : "outlined"}
|
||||||
|
onClick={() => handleSort("title")}
|
||||||
|
startIcon={currentOrder.type === "title" ? currentOrder.icon : <SortByAlphaIcon />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '0 8px 8px 0',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: currentOrder.type === "title" ? 'bold' : 'normal',
|
||||||
|
minWidth: '100px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentOrder.type === "title" ? currentOrder.label : "Title"}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Active sort indicator */}
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={`Active sort: ${currentOrder.label}`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
icon={currentOrder.icon}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'medium',
|
||||||
|
'& .MuiChip-icon': {
|
||||||
|
fontSize: '1rem'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Info about keyword system */}
|
||||||
|
{search && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2, borderRadius: 2 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
🎯 <strong>Keyword-Based Search:</strong> Search prioritizes keywords, then authors, then titles.
|
||||||
|
Try combining keywords for more precise results!
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Publications grid */}
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{data?.map(pub => (
|
||||||
|
<Grid item xs={12} key={pub.id}>
|
||||||
|
<Card
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
boxShadow: '0 12px 40px rgba(0,0,0,0.12)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
{/* Title */}
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
component="h2"
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'primary.main',
|
||||||
|
lineHeight: 1.3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pub.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Main information */}
|
||||||
|
<Stack direction="row" spacing={3} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{/* Date */}
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<CalendarTodayIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{new Date(pub.upload_date).toLocaleDateString()}
|
||||||
|
{pub.year && ` (${pub.year})`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Authors */}
|
||||||
|
{pub.authors && pub.authors.length > 0 && (
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<PersonIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{Array.isArray(pub.authors)
|
||||||
|
? pub.authors.map(a => a.name ?? a).join(", ")
|
||||||
|
: pub.authors}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Journal/Conference */}
|
||||||
|
{pub.journal && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
|
||||||
|
<MenuBookIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight="medium">
|
||||||
|
Published in:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={pub.journal}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
ml: 3,
|
||||||
|
backgroundColor: 'info.50',
|
||||||
|
borderColor: 'info.200',
|
||||||
|
color: 'info.700',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'info.100',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keywords */}
|
||||||
|
{pub.keywords && pub.keywords.length > 0 && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
|
||||||
|
<LocalOfferIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight="medium">
|
||||||
|
Keywords:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" flexWrap="wrap" gap={1} sx={{ ml: 3 }}>
|
||||||
|
{(Array.isArray(pub.keywords) ? pub.keywords : []).map((keyword, index) => (
|
||||||
|
<Chip
|
||||||
|
key={index}
|
||||||
|
label={keyword.name ?? keyword}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'primary.50',
|
||||||
|
color: 'primary.700',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.100',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DOI */}
|
||||||
|
{pub.doi && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
|
||||||
|
<LinkIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight="medium">
|
||||||
|
DOI:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={pub.doi}
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
href={`https://doi.org/${pub.doi}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
clickable
|
||||||
|
sx={{
|
||||||
|
ml: 3,
|
||||||
|
backgroundColor: 'success.50',
|
||||||
|
borderColor: 'success.200',
|
||||||
|
color: 'success.700',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'success.100',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alert if no keywords */}
|
||||||
|
{(!pub.keywords || pub.keywords.length === 0) && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
⚠️ This publication has no keywords! The system relies on keywords for search.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box display="flex" justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PictureAsPdfIcon />}
|
||||||
|
href={`${config.API_URL}/download/${pub.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View PDF
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Message for no results */}
|
||||||
|
{data?.length === 0 && (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
sx={{ py: 8 }}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
No publications found
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Try modifying your search terms
|
||||||
|
</Typography>
|
||||||
|
{search && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2, maxWidth: 500 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
💡 <strong>Tip:</strong> The system primarily searches in keywords.
|
||||||
|
Try more generic terms or different combinations!
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/SearchBar.jsx
Normal file
51
frontend/src/components/SearchBar.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { TextField, Box, Container, Paper } from "@mui/material";
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import { InputAdornment } from "@mui/material";
|
||||||
|
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export default function SearchBar({ value, onChange }) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mb: 4 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
maxWidth: 600,
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder="Search by keywords, authors or title..."
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
sx: {
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none'
|
||||||
|
},
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none'
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none'
|
||||||
|
},
|
||||||
|
padding: '8px 16px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
inputProps={{ "aria-label": "search" }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
567
frontend/src/components/UploadModal.jsx
Normal file
567
frontend/src/components/UploadModal.jsx
Normal file
|
|
@ -0,0 +1,567 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
Typography,
|
||||||
|
Stack,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Divider,
|
||||||
|
LinearProgress,
|
||||||
|
Paper,
|
||||||
|
Grid
|
||||||
|
} from "@mui/material";
|
||||||
|
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
|
import PublishIcon from '@mui/icons-material/Publish';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import LoginIcon from '@mui/icons-material/Login';
|
||||||
|
import InfoIcon from '@mui/icons-material/Info';
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export default function UploadModal({ open, onClose, onUploadSuccess }) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: "",
|
||||||
|
authors: "",
|
||||||
|
year: "",
|
||||||
|
journal: "",
|
||||||
|
doi: "", // ADDED DOI FIELD
|
||||||
|
});
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [bibtex, setBibtex] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e) => setFile(e.target.files[0]);
|
||||||
|
const handleBibtexChange = (e) => setBibtex(e.target.files[0]);
|
||||||
|
|
||||||
|
// 🎯 NEW LOGIC: Determine if fields are required
|
||||||
|
const fieldsRequired = !bibtex; // If no bibtex, fields are required
|
||||||
|
const isFormValid = fieldsRequired
|
||||||
|
? (form.title && form.authors && form.year && form.journal) // Case 1: all fields required
|
||||||
|
: true; // Case 2: with bibtex, fields not required
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setResult({ error: "You must be logged in to upload publications" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 CUSTOM VALIDATION
|
||||||
|
if (!file) {
|
||||||
|
setResult({ error: "You must select a document to upload" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldsRequired && !isFormValid) {
|
||||||
|
setResult({ error: "Fill in all required fields or upload a BibTeX file" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
if (bibtex) formData.append("bibtex", bibtex);
|
||||||
|
|
||||||
|
// 🎯 CONDITIONAL FIELD SENDING: only if filled or required
|
||||||
|
if (form.title || fieldsRequired) formData.append("title", form.title);
|
||||||
|
if (form.authors || fieldsRequired) formData.append("authors", form.authors);
|
||||||
|
if (form.year || fieldsRequired) formData.append("year", form.year);
|
||||||
|
if (form.journal || fieldsRequired) formData.append("journal", form.journal);
|
||||||
|
if (form.doi) formData.append("doi", form.doi); // ADDED DOI
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const uploadUrl = `${config.API_URL}/upload/`;
|
||||||
|
const res = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
setResult({ error: "Unauthorized. Please login to upload publications." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
setResult({ error: errorData.detail || "Error during upload" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setResult(data);
|
||||||
|
|
||||||
|
// Auto-close and reload after success
|
||||||
|
setTimeout(() => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
if (onUploadSuccess) {
|
||||||
|
onUploadSuccess();
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setResult({ error: "Connection error during upload" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm({ title: "", authors: "", year: "", journal: "", doi: "" }); // ADDED DOI
|
||||||
|
setFile(null);
|
||||||
|
setBibtex(null);
|
||||||
|
setResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If user is not logged in, show warning message
|
||||||
|
const isDisabled = !token;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
// 🔧 FIX: Limit maximum height and enable scroll
|
||||||
|
maxHeight: '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{
|
||||||
|
pb: 1.5, // 🔧 FIX: Reduced padding
|
||||||
|
background: isDisabled
|
||||||
|
? 'linear-gradient(135deg, rgba(245, 101, 101, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
// 🔧 FIX: Prevent title from resizing
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{isDisabled ? <LockIcon color="error" fontSize="small" /> : <CloudUploadIcon color="primary" fontSize="small" />}
|
||||||
|
<Typography variant="h6" fontWeight="bold" fontSize="1.1rem">
|
||||||
|
Upload a new publication
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={handleClose} size="small">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
{loading && <LinearProgress sx={{ flexShrink: 0 }} />}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} encType="multipart/form-data" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{/* 🔧 FIX: DialogContent with scroll enabled */}
|
||||||
|
<DialogContent sx={{
|
||||||
|
py: 2, // 🔧 FIX: Reduced vertical padding
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto', // 🔧 FIX: Enable automatic scroll
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
width: '8px'
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
background: '#f1f1f1',
|
||||||
|
borderRadius: '4px'
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: '#c1c1c1',
|
||||||
|
borderRadius: '4px',
|
||||||
|
'&:hover': {
|
||||||
|
background: '#a1a1a1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Stack spacing={2}> {/* 🔧 FIX: Reduced spacing from 3 to 2 */}
|
||||||
|
{/* Warning for users not logged in */}
|
||||||
|
{isDisabled && (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
icon={<LockIcon fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
py: 1, // 🔧 FIX: Reduced padding
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
fontSize: '0.8rem' // 🔧 FIX: Reduced font size
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" fontWeight="bold" gutterBottom fontSize="0.85rem">
|
||||||
|
🔒 Login required
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontSize="0.75rem">
|
||||||
|
To upload publications you must be registered and logged in.
|
||||||
|
Click the user icon in the top right to login or register.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🎯 NEW: Dynamic information about requirements */}
|
||||||
|
{!isDisabled && (
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
icon={<InfoIcon fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
py: 1, // 🔧 FIX: Reduced padding
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
fontSize: '0.8rem' // 🔧 FIX: Reduced font size
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bibtex ? (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight="bold" gutterBottom fontSize="0.85rem">
|
||||||
|
📋 BibTeX mode active
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontSize="0.75rem">
|
||||||
|
You have uploaded a BibTeX file. Metadata will be automatically extracted from the BibTeX file, and the fields below become optional.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight="bold" gutterBottom fontSize="0.85rem">
|
||||||
|
📝 Manual mode active
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontSize="0.75rem">
|
||||||
|
Fill in all fields below or upload a BibTeX file for automatic metadata extraction.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload Files Section */}
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2, // 🔧 FIX: Reduced padding from 3 to 2
|
||||||
|
borderRadius: 3,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderWidth: 2,
|
||||||
|
bgcolor: isDisabled ? 'action.disabledBackground' : 'background.default',
|
||||||
|
borderColor: isDisabled ? 'action.disabled' : 'primary.main',
|
||||||
|
opacity: isDisabled ? 0.6 : 1,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: isDisabled ? 'action.disabled' : 'primary.main',
|
||||||
|
bgcolor: isDisabled ? 'action.disabledBackground' : 'primary.50'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.5}> {/* 🔧 FIX: Reduced spacing */}
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={isDisabled ? "text.disabled" : "primary"}
|
||||||
|
fontSize="0.9rem" // 🔧 FIX: Reduced font size
|
||||||
|
>
|
||||||
|
📁 File to upload
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
startIcon={<DescriptionIcon fontSize="small" />}
|
||||||
|
size="medium" // 🔧 FIX: Changed from large to medium
|
||||||
|
disabled={isDisabled}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
py: 1.5, // 🔧 FIX: Reduced padding
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file ? `📄 ${file.name}` : "Select PDF/DOCX/LaTeX document *"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="file"
|
||||||
|
accept=".pdf,.docx,.tex,.latex"
|
||||||
|
hidden
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
startIcon={<UploadFileIcon fontSize="small" />}
|
||||||
|
size="medium" // 🔧 FIX: Changed from large to medium
|
||||||
|
disabled={isDisabled}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
py: 1.5, // 🔧 FIX: Reduced padding
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.85rem', // 🔧 FIX: Reduced font size
|
||||||
|
// 🎯 Highlight BibTeX if loaded
|
||||||
|
borderColor: bibtex ? 'success.main' : undefined,
|
||||||
|
backgroundColor: bibtex ? 'success.50' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bibtex ? `📋 ${bibtex.name}` : "BibTeX File (optional)"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="bibtex"
|
||||||
|
accept=".bib"
|
||||||
|
hidden
|
||||||
|
disabled={isDisabled}
|
||||||
|
onChange={handleBibtexChange}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Form Fields - IMPROVED AND COMPACT LAYOUT */}
|
||||||
|
<Stack spacing={1.5}> {/* 🔧 FIX: Reduced spacing */}
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={isDisabled ? "text.disabled" : "primary"}
|
||||||
|
fontSize="0.9rem" // 🔧 FIX: Reduced font size
|
||||||
|
>
|
||||||
|
📝 Publication information {fieldsRequired ? "(required)" : "(optional)"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="title"
|
||||||
|
label={`Title${fieldsRequired ? ' *' : ''}`}
|
||||||
|
value={form.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={fieldsRequired}
|
||||||
|
fullWidth
|
||||||
|
disabled={isDisabled}
|
||||||
|
multiline
|
||||||
|
rows={1} // 🔧 FIX: Reduced from 2 to 1 row
|
||||||
|
size="small" // 🔧 FIX: Added small size
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
name="authors"
|
||||||
|
label={`Authors (separated by commas)${fieldsRequired ? ' *' : ''}`}
|
||||||
|
value={form.authors}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={fieldsRequired}
|
||||||
|
fullWidth
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder="John Smith, Jane Doe, Mark Johnson"
|
||||||
|
size="small" // 🔧 FIX: Added small size
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* IMPROVED LAYOUT WITH GRID INSTEAD OF FLEX BOX */}
|
||||||
|
<Grid container spacing={1.5}> {/* 🔧 FIX: Reduced spacing */}
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<TextField
|
||||||
|
name="year"
|
||||||
|
label={`Year${fieldsRequired ? ' *' : ''}`}
|
||||||
|
type="number"
|
||||||
|
value={form.year}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={fieldsRequired}
|
||||||
|
disabled={isDisabled}
|
||||||
|
fullWidth
|
||||||
|
size="small" // 🔧 FIX: Added small size
|
||||||
|
inputProps={{
|
||||||
|
min: 1900,
|
||||||
|
max: new Date().getFullYear() + 5
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={8}>
|
||||||
|
<TextField
|
||||||
|
name="journal"
|
||||||
|
label={`Journal/Conference${fieldsRequired ? ' *' : ''}`}
|
||||||
|
value={form.journal}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={fieldsRequired}
|
||||||
|
disabled={isDisabled}
|
||||||
|
fullWidth
|
||||||
|
placeholder="Nature, IEEE Transactions, ICML 2024, etc."
|
||||||
|
size="small" // 🔧 FIX: Added small size
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* NEW DOI FIELD - COMPACT */}
|
||||||
|
<TextField
|
||||||
|
name="doi"
|
||||||
|
label="DOI (optional)"
|
||||||
|
value={form.doi}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder="e.g. 10.1000/182"
|
||||||
|
helperText="Format: 10.xxxx/xxxxx (leave blank if not available)"
|
||||||
|
size="small" // 🔧 FIX: Added small size
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
|
||||||
|
},
|
||||||
|
'& .MuiFormHelperText-root': {
|
||||||
|
fontSize: '0.7rem' // 🔧 FIX: Reduced helper text font size
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
pattern: "^10\\.\\d{4,}/[-._;()/:\\w\\[\\]]+$"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Result Section */}
|
||||||
|
{result && (
|
||||||
|
<Alert
|
||||||
|
severity={result.error ? "error" : "success"}
|
||||||
|
icon={result.error ? undefined : <CheckCircleIcon fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
py: 1, // 🔧 FIX: Reduced padding
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
fontSize: '0.8rem' // 🔧 FIX: Reduced font size
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.error ? (
|
||||||
|
result.error
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight="bold" gutterBottom fontSize="0.85rem">
|
||||||
|
🎉 Publication uploaded successfully!
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontSize="0.75rem">
|
||||||
|
"{result.title}" has been added to the database. The window will close automatically and the page will be refreshed.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<Divider sx={{ flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 🔧 FIX: Compact DialogActions */}
|
||||||
|
<DialogActions sx={{ p: 2, gap: 1.5, flexShrink: 0 }}> {/* 🔧 FIX: Reduced padding and gap */}
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
variant="outlined"
|
||||||
|
size="medium" // 🔧 FIX: Changed from large to medium
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 80, // 🔧 FIX: Reduced minWidth
|
||||||
|
fontSize: '0.85rem' // 🔧 FIX: Reduced font size
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDisabled ? "Close" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isDisabled ? (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<LoginIcon fontSize="small" />}
|
||||||
|
size="medium" // 🔧 FIX: Changed from large to medium
|
||||||
|
onClick={handleClose}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 120, // 🔧 FIX: Reduced minWidth
|
||||||
|
fontSize: '0.85rem', // 🔧 FIX: Reduced font size
|
||||||
|
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'linear-gradient(135deg, #eab308 0%, #ca8a04 100%)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to Login
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || !file || !isFormValid || result?.id}
|
||||||
|
size="medium" // 🔧 FIX: Changed from large to medium
|
||||||
|
startIcon={<PublishIcon fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 120, // 🔧 FIX: Reduced minWidth
|
||||||
|
fontSize: '0.85rem', // 🔧 FIX: Reduced font size
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
'&:hover': {
|
||||||
|
background: 'linear-gradient(135deg, #5a67d8 0%, #6b4c93 100%)',
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)'
|
||||||
|
},
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Uploading..." : "Upload publication"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
frontend/src/components/UserMenu.jsx
Normal file
191
frontend/src/components/UserMenu.jsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Menu, MenuItem, Avatar, Typography, Box, IconButton } from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import LoginDialog from "./LoginDialog";
|
||||||
|
import LoginIcon from '@mui/icons-material/Login';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
|
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||||
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
|
|
||||||
|
export default function UserMenu() {
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
const { token, logout, userInfo, loading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleMenu = (e) => setAnchorEl(e.currentTarget);
|
||||||
|
const handleClose = () => setAnchorEl(null);
|
||||||
|
|
||||||
|
// Function to get display name
|
||||||
|
const getDisplayName = () => {
|
||||||
|
if (!userInfo) return "User";
|
||||||
|
|
||||||
|
// If has first and last name, show those
|
||||||
|
if (userInfo.first_name && userInfo.last_name) {
|
||||||
|
return `${userInfo.first_name} ${userInfo.last_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If has only first name
|
||||||
|
if (userInfo.first_name) {
|
||||||
|
return userInfo.first_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise show email (or part of it)
|
||||||
|
if (userInfo.email) {
|
||||||
|
return userInfo.email.split('@')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return "User";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initials for avatar
|
||||||
|
const getInitials = () => {
|
||||||
|
if (!userInfo) return "U";
|
||||||
|
|
||||||
|
if (userInfo.first_name && userInfo.last_name) {
|
||||||
|
return `${userInfo.first_name.charAt(0)}${userInfo.last_name.charAt(0)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo.first_name) {
|
||||||
|
return userInfo.first_name.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo.email) {
|
||||||
|
return userInfo.email.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "U";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!token ? (
|
||||||
|
// Login button when not logged in
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<LoginIcon />}
|
||||||
|
onClick={() => setLoginOpen(true)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: '0 8px 25px rgba(0,0,0,0.3)'
|
||||||
|
},
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textTransform: 'none',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
// Button with name when logged in
|
||||||
|
<Button
|
||||||
|
onClick={handleMenu}
|
||||||
|
endIcon={<ExpandMoreIcon />}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||||
|
},
|
||||||
|
textTransform: 'none',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
maxWidth: '220px',
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<Avatar sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.3)',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
{getInitials()}
|
||||||
|
</Avatar>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '140px',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Loading..." : getDisplayName()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dropdown menu for logged in user */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={handleClose}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
mt: 1,
|
||||||
|
borderRadius: 2,
|
||||||
|
minWidth: 200,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* User info in menu */}
|
||||||
|
{userInfo && (
|
||||||
|
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Typography variant="body2" fontWeight="bold" color="text.primary">
|
||||||
|
{getDisplayName()}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{userInfo.email}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/me");
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
sx={{ gap: 1.5, py: 1.5 }}
|
||||||
|
>
|
||||||
|
<MenuBookIcon fontSize="small" />
|
||||||
|
My publications
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
sx={{ gap: 1.5, py: 1.5, color: 'error.main' }}
|
||||||
|
>
|
||||||
|
<LogoutIcon fontSize="small" />
|
||||||
|
Logout
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Login dialog */}
|
||||||
|
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/config.js
Normal file
7
frontend/src/config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const API_URL = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:8000';
|
||||||
|
const config = {
|
||||||
|
API_URL
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
export { API_URL };
|
||||||
69
frontend/src/context/AuthContext.jsx
Normal file
69
frontend/src/context/AuthContext.jsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
import { API_URL } from "../api/client";
|
||||||
|
|
||||||
|
const AuthContext = createContext();
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem("access_token") || null);
|
||||||
|
const [userInfo, setUserInfo] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Funzione per recuperare info utente
|
||||||
|
const fetchUserInfo = async (authToken) => {
|
||||||
|
if (!authToken) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/users/me`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const userData = await response.json();
|
||||||
|
setUserInfo(userData);
|
||||||
|
} else {
|
||||||
|
console.error('Errore nel recupero info utente:', response.status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel recupero info utente:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem("access_token", token);
|
||||||
|
fetchUserInfo(token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
setUserInfo(null);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
setToken(null);
|
||||||
|
setUserInfo(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{
|
||||||
|
token,
|
||||||
|
setToken,
|
||||||
|
logout,
|
||||||
|
userInfo,
|
||||||
|
setUserInfo,
|
||||||
|
loading,
|
||||||
|
refetchUserInfo: () => fetchUserInfo(token)
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
40
frontend/src/context/ThemeContext.jsx
Normal file
40
frontend/src/context/ThemeContext.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import { CssBaseline } from '@mui/material';
|
||||||
|
import { lightTheme, darkTheme } from '../theme/theme';
|
||||||
|
|
||||||
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomThemeProvider = ({ children }) => {
|
||||||
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
return saved ? JSON.parse(saved) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
setDarkMode(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const theme = darkMode ? darkTheme : lightTheme;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
frontend/src/index.css
Normal file
38
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(99, 102, 241, 0.6);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
transition: color 0.3s ease, background-color 0.3s ease;
|
||||||
|
}
|
||||||
16
frontend/src/index.js
Normal file
16
frontend/src/index.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
17
frontend/src/main.jsx
Normal file
17
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<CssBaseline />
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
23
frontend/src/pages/HomePage.jsx
Normal file
23
frontend/src/pages/HomePage.jsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import SearchBar from "../components/SearchBar";
|
||||||
|
import PublicationList from "../components/PublicationList";
|
||||||
|
import UploadModal from "../components/UploadModal";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [refresh, setRefresh] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 4 }}>
|
||||||
|
<SearchBar value={search} onChange={setSearch} />
|
||||||
|
<PublicationList search={search} key={refresh} />
|
||||||
|
<UploadModal
|
||||||
|
open={uploadOpen}
|
||||||
|
onClose={() => setUploadOpen(false)}
|
||||||
|
onUploadSuccess={() => setRefresh(r => !r)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
frontend/src/pages/UserPublicationsPage.jsx
Normal file
486
frontend/src/pages/UserPublicationsPage.jsx
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { fetchUserPublications, deletePublication } from "../api/publications";
|
||||||
|
import {
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Container,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Grid,
|
||||||
|
Stack,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
ButtonGroup,
|
||||||
|
IconButton,
|
||||||
|
Tooltip
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
|
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
||||||
|
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
|
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||||
|
import SortIcon from '@mui/icons-material/Sort';
|
||||||
|
import SortByAlphaIcon from '@mui/icons-material/SortByAlpha';
|
||||||
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
|
import DeletePublicationDialog from "../components/DeletePublicationDialog";
|
||||||
|
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export default function UserPublicationsPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [orderBy, setOrderBy] = useState("date_desc");
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [publicationToDelete, setPublicationToDelete] = useState(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["userPublications", { token, orderBy }],
|
||||||
|
queryFn: fetchUserPublications,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation to delete a publication
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: ({ publicationId }) => deletePublication({ publicationId, token }),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Invalidate and reload publications list
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["userPublications"] });
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setPublicationToDelete(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Error deleting:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to open the confirmation dialog
|
||||||
|
const handleDeleteClick = (publication) => {
|
||||||
|
setPublicationToDelete(publication);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to confirm deletion
|
||||||
|
const handleConfirmDelete = (publicationId) => {
|
||||||
|
deleteMutation.mutate({ publicationId });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to get current sort information
|
||||||
|
const getOrderInfo = () => {
|
||||||
|
switch(orderBy) {
|
||||||
|
case "date_asc": return { type: "date", direction: "asc", label: "Date ascending", icon: <ArrowUpwardIcon /> };
|
||||||
|
case "date_desc": return { type: "date", direction: "desc", label: "Date descending", icon: <ArrowDownwardIcon /> };
|
||||||
|
case "title_asc": return { type: "title", direction: "asc", label: "Title A-Z", icon: <ArrowUpwardIcon /> };
|
||||||
|
case "title_desc": return { type: "title", direction: "desc", label: "Title Z-A", icon: <ArrowDownwardIcon /> };
|
||||||
|
default: return { type: "date", direction: "desc", label: "Date descending", icon: <ArrowDownwardIcon /> };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to handle sort button clicks
|
||||||
|
const handleSort = (type) => {
|
||||||
|
const currentOrder = getOrderInfo();
|
||||||
|
|
||||||
|
if (currentOrder.type === type) {
|
||||||
|
// If same type, toggle direction
|
||||||
|
const newDirection = currentOrder.direction === "asc" ? "desc" : "asc";
|
||||||
|
setOrderBy(`${type}_${newDirection}`);
|
||||||
|
} else {
|
||||||
|
// If different type, set default direction
|
||||||
|
const defaultDirection = type === "date" ? "desc" : "asc";
|
||||||
|
setOrderBy(`${type}_${defaultDirection}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Box display="flex" justifyContent="center" mt={4}>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Alert severity="error" sx={{ mt: 2, borderRadius: 2 }}>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentOrder = getOrderInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold', color: 'primary.main' }}>
|
||||||
|
📚 My Publications
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||||
|
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{data?.length || 0} publications uploaded
|
||||||
|
</Typography>
|
||||||
|
{data?.length > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={`Latest: ${new Date(data[0]?.upload_date).toLocaleDateString()}`}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sort controls */}
|
||||||
|
{data?.length > 1 && (
|
||||||
|
<Box display="flex" alignItems="center" gap={2}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<SortIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Sort:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ButtonGroup variant="outlined" size="small">
|
||||||
|
{/* Date button */}
|
||||||
|
<Button
|
||||||
|
variant={currentOrder.type === "date" ? "contained" : "outlined"}
|
||||||
|
onClick={() => handleSort("date")}
|
||||||
|
startIcon={currentOrder.type === "date" ? currentOrder.icon : <CalendarTodayIcon />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '8px 0 0 8px',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: currentOrder.type === "date" ? 'bold' : 'normal',
|
||||||
|
minWidth: '100px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentOrder.type === "date" ? currentOrder.label : "Date"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Title button */}
|
||||||
|
<Button
|
||||||
|
variant={currentOrder.type === "title" ? "contained" : "outlined"}
|
||||||
|
onClick={() => handleSort("title")}
|
||||||
|
startIcon={currentOrder.type === "title" ? currentOrder.icon : <SortByAlphaIcon />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: '0 8px 8px 0',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: currentOrder.type === "title" ? 'bold' : 'normal',
|
||||||
|
minWidth: '100px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentOrder.type === "title" ? currentOrder.label : "Title"}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Active sort indicator */}
|
||||||
|
{data?.length > 1 && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Chip
|
||||||
|
label={`Sort by: ${currentOrder.label}`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
icon={currentOrder.icon}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'medium',
|
||||||
|
'& .MuiChip-icon': {
|
||||||
|
fontSize: '1rem'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Delete error alert */}
|
||||||
|
{deleteMutation.error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||||
|
{deleteMutation.error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete success alert */}
|
||||||
|
{deleteMutation.isSuccess && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3, borderRadius: 2 }}>
|
||||||
|
Publication successfully deleted!
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Publications */}
|
||||||
|
{data?.length > 0 ? (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{data.map(pub => (
|
||||||
|
<Grid item xs={12} key={pub.id}>
|
||||||
|
<Card
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'primary.100',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: '0 8px 25px rgba(0,0,0,0.12)',
|
||||||
|
borderColor: 'primary.300'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
{/* Header with title and delete button */}
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 2 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
component="h2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'primary.main',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
flex: 1,
|
||||||
|
mr: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pub.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Tooltip title="Delete publication">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleDeleteClick(pub)}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'error.50',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main information */}
|
||||||
|
<Stack direction="row" spacing={3} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||||
|
{/* Date */}
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<CalendarTodayIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Uploaded on {new Date(pub.upload_date).toLocaleDateString()}
|
||||||
|
{pub.year && ` (Year: ${pub.year})`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
|
{pub.filename && (
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<PictureAsPdfIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{pub.filename}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Journal/Conference */}
|
||||||
|
{pub.journal && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
|
||||||
|
<MenuBookIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight="medium">
|
||||||
|
Published in:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={pub.journal}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
ml: 3,
|
||||||
|
backgroundColor: 'info.50',
|
||||||
|
borderColor: 'info.200',
|
||||||
|
color: 'info.700',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'info.100',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keywords */}
|
||||||
|
{pub.keywords && pub.keywords.length > 0 && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
|
||||||
|
<LocalOfferIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight="medium">
|
||||||
|
Keywords:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" flexWrap="wrap" gap={1} sx={{ ml: 3 }}>
|
||||||
|
{pub.keywords.map((keyword, index) => (
|
||||||
|
<Chip
|
||||||
|
key={index}
|
||||||
|
label={keyword.name}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'secondary.50',
|
||||||
|
color: 'secondary.700',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'secondary.100',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DOI */}
|
||||||
|
{pub.doi && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
|
||||||
|
<LinkIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight="medium">
|
||||||
|
DOI:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={pub.doi}
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
href={`https://doi.org/${pub.doi}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
clickable
|
||||||
|
sx={{
|
||||||
|
ml: 3,
|
||||||
|
backgroundColor: 'success.50',
|
||||||
|
borderColor: 'success.200',
|
||||||
|
color: 'success.700',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'success.100',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Authors */}
|
||||||
|
{pub.authors && pub.authors.length > 0 && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1} sx={{ mb: 1 }}>
|
||||||
|
<PersonIcon fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2" color="text.secondary" fontWeight="medium">
|
||||||
|
Authors:
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.primary" sx={{ ml: 3 }}>
|
||||||
|
{pub.authors.map(a => a.name).join(", ")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alert if no keywords */}
|
||||||
|
{(!pub.keywords || pub.keywords.length === 0) && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
⚠️ This publication has no keywords! The search system relies on keywords.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
Publication ID: #{pub.id}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PictureAsPdfIcon />}
|
||||||
|
href={`${config.API_URL}/download/${pub.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View PDF
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
/* Empty state */
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
sx={{
|
||||||
|
py: 8,
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloudUploadIcon sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
You haven't uploaded any publications yet
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Use the UPLOAD button in the header to add your first publication
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<CloudUploadIcon />}
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to homepage
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeletePublicationDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setPublicationToDelete(null);
|
||||||
|
}}
|
||||||
|
publication={publicationToDelete}
|
||||||
|
onConfirmDelete={handleConfirmDelete}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
frontend/src/theme/theme.js
Normal file
277
frontend/src/theme/theme.js
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f4ff',
|
||||||
|
100: '#e0e7ff',
|
||||||
|
200: '#c7d2fe',
|
||||||
|
300: '#a5b4fc',
|
||||||
|
400: '#818cf8',
|
||||||
|
500: '#6366f1',
|
||||||
|
600: '#4f46e5',
|
||||||
|
700: '#4338ca',
|
||||||
|
800: '#3730a3',
|
||||||
|
900: '#312e81',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
50: '#fdf4ff',
|
||||||
|
100: '#fae8ff',
|
||||||
|
200: '#f5d0fe',
|
||||||
|
300: '#f0abfc',
|
||||||
|
400: '#e879f9',
|
||||||
|
500: '#d946ef',
|
||||||
|
600: '#c026d3',
|
||||||
|
700: '#a21caf',
|
||||||
|
800: '#86198f',
|
||||||
|
900: '#701a75',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: colors.primary[600],
|
||||||
|
light: colors.primary[400],
|
||||||
|
dark: colors.primary[800],
|
||||||
|
contrastText: '#ffffff',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: colors.secondary[600],
|
||||||
|
light: colors.secondary[400],
|
||||||
|
dark: colors.secondary[800],
|
||||||
|
contrastText: '#ffffff',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#f8fafc',
|
||||||
|
paper: '#ffffff',
|
||||||
|
card: '#ffffff',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#1e293b',
|
||||||
|
secondary: '#64748b',
|
||||||
|
},
|
||||||
|
grey: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: [
|
||||||
|
'Inter',
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"Segoe UI"',
|
||||||
|
'Roboto',
|
||||||
|
'"Helvetica Neue"',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif',
|
||||||
|
].join(','),
|
||||||
|
h4: {
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.025em',
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.025em',
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.025em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||||
|
border: '1px solid rgba(226, 232, 240, 0.8)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 12,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.4)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDialog: {
|
||||||
|
styleOverrides: {
|
||||||
|
paper: {
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const darkTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: {
|
||||||
|
main: colors.primary[400],
|
||||||
|
light: colors.primary[300],
|
||||||
|
dark: colors.primary[600],
|
||||||
|
contrastText: '#000000',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: colors.secondary[400],
|
||||||
|
light: colors.secondary[300],
|
||||||
|
dark: colors.secondary[600],
|
||||||
|
contrastText: '#000000',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#0f172a',
|
||||||
|
paper: '#1e293b',
|
||||||
|
card: '#334155',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: '#f8fafc',
|
||||||
|
secondary: '#cbd5e1',
|
||||||
|
},
|
||||||
|
grey: {
|
||||||
|
50: '#0f172a',
|
||||||
|
100: '#1e293b',
|
||||||
|
200: '#334155',
|
||||||
|
300: '#475569',
|
||||||
|
400: '#64748b',
|
||||||
|
500: '#94a3b8',
|
||||||
|
600: '#cbd5e1',
|
||||||
|
700: '#e2e8f0',
|
||||||
|
800: '#f1f5f9',
|
||||||
|
900: '#f8fafc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: [
|
||||||
|
'Inter',
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"Segoe UI"',
|
||||||
|
'Roboto',
|
||||||
|
'"Helvetica Neue"',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif',
|
||||||
|
].join(','),
|
||||||
|
h4: {
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.025em',
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.025em',
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.025em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||||
|
border: '1px solid rgba(71, 85, 105, 0.6)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 12,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 4px 12px rgba(129, 140, 248, 0.4)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(51, 65, 85, 0.5)',
|
||||||
|
'& fieldset': {
|
||||||
|
borderColor: 'rgba(71, 85, 105, 0.6)',
|
||||||
|
},
|
||||||
|
'&:hover fieldset': {
|
||||||
|
borderColor: 'rgba(129, 140, 248, 0.6)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDialog: {
|
||||||
|
styleOverrides: {
|
||||||
|
paper: {
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
|
||||||
|
border: '1px solid rgba(71, 85, 105, 0.6)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiAppBar: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.9) 0%, rgba(139, 92, 246, 0.9) 100%)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue