├── .env.example ├── .github └── workflows │ ├── build-docker-image worker.yml │ ├── build-docker-image-web.yml │ └── build-docker-image.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── README.md ├── app.py ├── auth │ ├── auth_route.py │ ├── decorators.py │ ├── models.py │ └── user_route.py ├── config.py ├── decorators.py ├── models.py └── routes │ ├── books.py │ ├── files.py │ ├── notes.py │ ├── profiles.py │ ├── settings.py │ └── tasks.py ├── assets ├── dependency_chart.svg ├── logo.svg └── mastodon-enable-share.png ├── docker-compose.yml ├── entrypoint.sh ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 334c6f93b980_add_rating_column.py │ ├── 4f342e92ab1f_add_profiles_table.py │ ├── 5c9e5e91be8f_add_notes_table.py │ ├── 82989182da4a_add_files_table.py │ ├── 89b6827408dd_add_user_settings_table.py │ ├── 8f8a46fec66b_add_auth_tables.py │ ├── 9232f4adc495_add_tasks_table.py │ ├── ae48d4b4f9d8_add_owner_id_column.py │ ├── c9e06189519a_add_quote_page_column_to_notes_table.py │ ├── ce3cee57bdd9_initial_migration.py │ └── e60887c12d97_add_author_column.py ├── poetry.lock ├── pyproject.toml ├── web ├── .dockerignore ├── .env.example ├── .env.production ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── README.md ├── index.html ├── inject-env.sh ├── nginx-custom.conf ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── ReadingSideDoodle.svg │ ├── fallback-cover.svg │ ├── feature_section_01.png │ ├── feature_section_02.png │ ├── feature_section_03.png │ ├── icon.svg │ ├── medal.svg │ ├── social-card.png │ ├── wave.svg │ └── wave_02.svg ├── src │ ├── AnimatedLayout.jsx │ ├── App.jsx │ ├── GlobalRouter.jsx │ ├── components │ │ ├── AccountTab.jsx │ │ ├── ActionsBookLibraryButton.jsx │ │ ├── AddToReadingListButton.jsx │ │ ├── BookStatsCard.jsx │ │ ├── CTA.jsx │ │ ├── Data │ │ │ ├── DataTab.jsx │ │ │ ├── FileList.jsx │ │ │ └── RequestData.jsx │ │ ├── ESCIcon.jsx │ │ ├── FeatureSection.jsx │ │ ├── Footer.jsx │ │ ├── GoogleLoginButton.jsx │ │ ├── Library │ │ │ ├── BookItem.jsx │ │ │ ├── BookRating.jsx │ │ │ ├── LibraryPane.jsx │ │ │ └── PaneTabView.jsx │ │ ├── MastodonTab.jsx │ │ ├── Navbar.jsx │ │ ├── NotesIcon.jsx │ │ ├── NotesView.jsx │ │ ├── OpenLibraryButton.jsx │ │ ├── SearchBar.jsx │ │ ├── SidebarNav.jsx │ │ ├── UpdateReadingStatusButton.jsx │ │ └── WelcomeModal.jsx │ ├── index.css │ ├── main.jsx │ ├── pages │ │ ├── BookDetails.jsx │ │ ├── Home.jsx │ │ ├── Library.jsx │ │ ├── Login.jsx │ │ ├── Profile.jsx │ │ ├── Register.jsx │ │ ├── Settings.jsx │ │ └── Verify.jsx │ ├── services │ │ ├── auth-header.jsx │ │ ├── auth.service.jsx │ │ ├── books.service.jsx │ │ ├── files.service.jsx │ │ ├── notes.service.jsx │ │ ├── openlibrary.service.jsx │ │ ├── profile.service.jsx │ │ ├── tasks.service.jsx │ │ └── userSettings.service.jsx │ ├── toast │ │ ├── Container.jsx │ │ ├── Context.jsx │ │ ├── Toast.jsx │ │ └── useToast.jsx │ └── useLibraryReducer.jsx ├── tailwind.config.js └── vite.config.js └── workers ├── Dockerfile ├── README.md ├── manage.py ├── poetry.lock ├── pyproject.toml └── src ├── example_worker.py ├── export_csv.py ├── export_html.py ├── export_json.py ├── html_templates └── all_books.html └── post_mastodon.py /.env.example: -------------------------------------------------------------------------------- 1 | FLASK_APP=api.app 2 | FLASK_DEBUG=1 3 | DATABASE_URL=postgresql://admin:password@localhost/booklogr 4 | AUTH_SECRET_KEY=this-really-needs-to-be-changed 5 | AUTH_ALLOW_REGISTRATION=True 6 | AUTH_REQUIRE_VERIFICATION=True 7 | GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com 8 | GOOGLE_CLIENT_SECRET=xxx 9 | EXPORT_FOLDER="export_data" 10 | 11 | # UNCOMMENT TO ADD A USER AT STARTUP, REQUIRED FOR DEMO MODE 12 | #AUTH_DEFAULT_USER=demo@booklogr.app 13 | #AUTH_DEFAULT_PASSWORD=demo 14 | 15 | MASTO_URL="" 16 | MASTO_ACCESS="" 17 | 18 | # docker-compose 19 | POSTGRES_USER=admin 20 | POSTGRES_PASSWORD=password 21 | POSTGRES_DB=booklogr -------------------------------------------------------------------------------- /.github/workflows/build-docker-image worker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Worker 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'New docker build for workers' 8 | required: true 9 | default: "latest" 10 | type: string 11 | 12 | jobs: 13 | # define job to build and publish docker image 14 | build-and-push-docker-image: 15 | name: Build Docker image and push to repositories 16 | # run only when code is compiling and tests are passing 17 | runs-on: ubuntu-latest 18 | 19 | # steps to perform in job 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | # setup Docker buld action 25 | - name: Set up Docker Buildx 26 | id: buildx 27 | uses: docker/setup-buildx-action@v3 28 | 29 | - name: Login to DockerHub 30 | uses: docker/login-action@v3 31 | with: 32 | username: ${{ secrets.DOCKER_HUB_USR }} 33 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 34 | 35 | # Extract metadata (tags, labels) for Docker 36 | # https://github.com/docker/metadata-action 37 | - name: Extract Docker metadata 38 | id: meta 39 | uses: docker/metadata-action@v5 40 | with: 41 | images: | 42 | docker.io/mozzo/booklogr-worker 43 | tags: | 44 | # set latest tag for default branch 45 | type=raw,value=latest,enable={{is_default_branch}} 46 | type=raw,value=${{ github.event.inputs.version }} 47 | 48 | - name: Build image and push to Docker Hub 49 | uses: docker/build-push-action@v6 50 | with: 51 | context: workers 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | 56 | - name: Image digest 57 | run: echo ${{ steps.docker_build.outputs.digest }} 58 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-image-web.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Web 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'New docker (WEB) build version' 8 | required: true 9 | default: "latest" 10 | type: string 11 | 12 | jobs: 13 | # define job to build and publish docker image 14 | build-and-push-docker-image: 15 | name: Build Docker image and push to repositories 16 | # run only when code is compiling and tests are passing 17 | runs-on: ubuntu-latest 18 | 19 | # steps to perform in job 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | # setup Docker buld action 25 | - name: Set up Docker Buildx 26 | id: buildx 27 | uses: docker/setup-buildx-action@v3 28 | 29 | - name: Login to DockerHub 30 | uses: docker/login-action@v3 31 | with: 32 | username: ${{ secrets.DOCKER_HUB_USR }} 33 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 34 | 35 | # Extract metadata (tags, labels) for Docker 36 | # https://github.com/docker/metadata-action 37 | - name: Extract Docker metadata 38 | id: meta 39 | uses: docker/metadata-action@v5 40 | with: 41 | images: | 42 | docker.io/mozzo/booklogr-web 43 | tags: | 44 | # set latest tag for default branch 45 | type=raw,value=latest,enable={{is_default_branch}} 46 | type=raw,value=${{ github.event.inputs.version }} 47 | 48 | - name: Build image and push to Docker Hub and GitHub Container Registry 49 | uses: docker/build-push-action@v6 50 | with: 51 | context: web 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | 56 | - name: Image digest 57 | run: echo ${{ steps.docker_build.outputs.digest }} 58 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish API 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'New docker build version' 8 | required: false 9 | default: "latest" 10 | type: string 11 | 12 | jobs: 13 | # define job to build and publish docker image 14 | build-and-push-docker-image: 15 | name: Build Docker image and push to repositories 16 | # run only when code is compiling and tests are passing 17 | runs-on: ubuntu-latest 18 | 19 | # steps to perform in job 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | # setup Docker buld action 25 | - name: Set up Docker Buildx 26 | id: buildx 27 | uses: docker/setup-buildx-action@v3 28 | 29 | - name: Login to DockerHub 30 | uses: docker/login-action@v3 31 | with: 32 | username: ${{ secrets.DOCKER_HUB_USR }} 33 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 34 | 35 | # Extract metadata (tags, labels) for Docker 36 | # https://github.com/docker/metadata-action 37 | - name: Extract Docker metadata 38 | id: meta 39 | uses: docker/metadata-action@v5 40 | with: 41 | images: | 42 | docker.io/mozzo/booklogr 43 | tags: | 44 | # set latest tag for default branch 45 | type=raw,value=latest,enable={{is_default_branch}} 46 | type=raw,value=${{ github.event.inputs.version }} 47 | 48 | - name: Build image and push to Docker Hub and GitHub Container Registry 49 | uses: docker/build-push-action@v6 50 | with: 51 | context: . 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | 56 | - name: Image digest 57 | run: echo ${{ steps.docker_build.outputs.digest }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .env 3 | export_data 4 | auth_db_vol 5 | booklogr -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 6 | ## [Unreleased] 7 | 8 | ## [1.3.0] - 2025-01-02 9 | ### Added 10 | - Docker image for background worker 11 | 12 | ### Fixed 13 | - Tasks not being able to be created. 14 | - Incorrect volume paths in provided `docker-compose.yml` file for postgres and auth-server services. 15 | 16 | ### Changed 17 | - Account settings now have a more responsive layout. 18 | - Worker management CLI now connects to the database via environment variable `DATABASE_URL` 19 | - Updated `auth-server` to version 1.1.1 in the provided `docker-compose.yml` file 20 | - Demo mode is now enabled with environment variables instead of requiring building from source. 21 | 22 | ## [1.2.0] - 2024-12-27 23 | ### Added 24 | - Docker images for the API and web frontend. https://hub.docker.com/repository/docker/mozzo/booklogr-web and https://hub.docker.com/repository/docker/mozzo/booklogr 25 | - Search now shows an error message when failing to retrieve results from OpenLibrary. 26 | - Small transition when switching pages. 27 | - Option to export data in JSON and HTML format. 28 | - Worker management CLI tool. 29 | - Quotes with page numbers can now be added. 30 | - Event sharing - automatically post to Mastodon when finished reading a book. 31 | 32 | ### Fixed 33 | - The navigation menu now disappears correctly after login and sidebar/mobile navigation will be visible without requiring a refresh of the page. 34 | - Public profile not returning any information if there where no public notes in any books. 35 | - 401 errors now redirect you back to the login page. This normally happens when the JWT token has expired. 36 | - Error toast being shown on settings page when no exports files where available. 37 | 38 | ### Changed 39 | - The provided docker-compose does no longer require a locally built `auth-server` image. Instead it pulls the image from Docker Hub. The users database is now also stored in sqlite instead of postgres, eliminating the need for a secondary postgres server container. 40 | - The API is now available as a prebuilt docker image. The provided docker-compose has been changed to reflect this instead of building locally. 41 | - Changed the color of the mobile navigation bar buttons to better match the styling of other similar elements. 42 | - The layout in data tab on the settings page is now responsive. 43 | - Notes in the web interface has now been renamed to Notes & Quotes, to accomadate the new quotes feature. 44 | 45 | ## [1.1.0] - 2024-09-06 46 | 47 | ### Added 48 | - Welcome screen on first login. 49 | - Basic framework for handling long running background tasks. 50 | - API validation for editing current page, rating and status. 51 | - "No results found" text added when there are no results when searching. 52 | - Error text is now shown when trying to give a higher rating than 5 or lower than 0. 53 | - Error text is now shown when trying to set a current page higher than the books total page or lower than 0. 54 | - Add CSV export functionality 55 | - Account settings page (no working functionality yet) 56 | 57 | ### Fixed 58 | - Reading progress percentage no longer shown as over 100% if current page exceeded total pages on a book. 59 | - Search results will now only show books that have a corresponing ISBN. Without this would cause the search results to show up empty and endlessly load. 60 | 61 | ### Changed 62 | - Number of pages shown on a book page now shows 0 instead of a loading bar if there is no data from OpenLibrary. 63 | - Book covers will now fallback to a static "cover not found" image instead of endlessly showing a loading bar. 64 | - Search results now uses the same loading animation as everything else. 65 | - Reading status buttons in book action menu now has icons. 66 | 67 | ### Removed 68 | - Profile creation on profile page. Instead the user will create it's profile in the newly added welcome screen. 69 | 70 | 71 | ## [1.0.0] - 2024-07-21 72 | 73 | ### Added 74 | 75 | - Look up books by title or isbn, powered by OpenLibrary. 76 | - Add books to your reading lists. 77 | - Remove books from your reading lists. 78 | - Add notes to read books. 79 | - Rate your read books, 0.5-5 stars. 80 | - Public profile of your reading lists. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.4 2 | WORKDIR /app 3 | 4 | COPY README.md pyproject.toml entrypoint.sh ./ 5 | COPY migrations ./migrations 6 | COPY api ./api 7 | 8 | RUN pip install . 9 | ENV FLASK_ENV production 10 | RUN chmod +x entrypoint.sh 11 | 12 | EXPOSE 5000 13 | ENTRYPOINT ["/app/entrypoint.sh"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | 5 | 6 | 7 | 8 |

BookLogr

9 | 10 |

11 | A simple, self-hosted service to keep track of your personal library. 12 |
13 | 🗒️Explore the docs » 14 |
15 |
16 | 💻View Demo | 17 | 🐞Report Bug | 18 | ✨Request Feature | 19 | 👷Service status 20 |

21 |
22 | 23 | ## 👉About the project 24 | BookLogr is a web app designed to help you manage your personal book library with ease. This self-hosted service ensures that you have complete control over your data, providing a secure and private way to keep track of all the books you own, read, or wish to read. 25 | Optionally you can also display your library proudly to the public, sharing it with your friends and family. 26 | 27 | 28 | 29 | 30 | > [!IMPORTANT] 31 | > * This project is under **active** development. 32 | > * Expect bugs and breaking changes. 33 | 34 | ## ✨Features 35 | * Easily look up books by title or isbn. Powered by [OpenLibrary](https://openlibrary.org/) 36 | * Add books to predefined lists, reading, already read and to be read. 37 | * Keep track of what page you are on with your current book. 38 | * Have a public profile of your library available to all. 39 | * Rate you read books with 0.5-5 stars. 40 | * Take short notes and quotes on the books you read. 41 | * Automatically share your progress to Mastodon. 42 | 43 | ## 🖥 Install 44 | BookLogr is made to be self-hosted and run on your own hardware. 45 | 46 | See [how to install with Docker](https://github.com/Mozzo1000/booklogr/wiki/Install-with-Docker) to get started. 47 | 48 | ## 🛠️Development 49 | See [development instructions](https://github.com/Mozzo1000/booklogr/wiki/Development) on the wiki to get started. 50 | 51 | ## 🙌Contributing 52 | All contributions are welcome! 53 | 54 | ## 🧾License 55 | This project is licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full license text. -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # minimal-reading-api -------------------------------------------------------------------------------- /api/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_jwt_extended import JWTManager, jwt_required 3 | from flask_cors import CORS 4 | from api.config import Config 5 | from flask_migrate import Migrate 6 | from api.models import db, ma 7 | from api.routes.books import books_endpoint 8 | from api.routes.profiles import profiles_endpoint 9 | from api.routes.notes import notes_endpoint 10 | from api.routes.tasks import tasks_endpoint 11 | from api.routes.files import files_endpoint 12 | from api.routes.settings import settings_endpoint 13 | from flasgger import Swagger 14 | from api.auth.auth_route import auth_endpoint 15 | from api.auth.user_route import user_endpoint 16 | 17 | app = Flask(__name__) 18 | CORS(app) 19 | 20 | app.config.from_object(Config) 21 | swagger = Swagger(app) 22 | 23 | db.init_app(app) 24 | ma.init_app(app) 25 | migrate = Migrate(app, db) 26 | jwt = JWTManager(app) 27 | 28 | app.register_blueprint(books_endpoint) 29 | app.register_blueprint(profiles_endpoint) 30 | app.register_blueprint(notes_endpoint) 31 | app.register_blueprint(tasks_endpoint) 32 | app.register_blueprint(files_endpoint) 33 | app.register_blueprint(settings_endpoint) 34 | 35 | app.register_blueprint(auth_endpoint) 36 | app.register_blueprint(user_endpoint) 37 | 38 | @app.route("/") 39 | def index(): 40 | return { 41 | "name": "minimal-reading-api", 42 | "version": "1.0.0" 43 | } -------------------------------------------------------------------------------- /api/auth/decorators.py: -------------------------------------------------------------------------------- 1 | from flask_jwt_extended import verify_jwt_in_request, get_jwt 2 | from flask import jsonify 3 | from functools import wraps 4 | 5 | def require_role(role): 6 | def wrapper(fn): 7 | @wraps(fn) 8 | def decorator(*args, **kwargs): 9 | verify_jwt_in_request() 10 | claims = get_jwt() 11 | if claims["role"] == role: 12 | return fn(*args, **kwargs) 13 | else: 14 | return jsonify({'error': 'Permission denied', 'message': 'You do not have the required permission.'}), 403 15 | return decorator 16 | return wrapper 17 | 18 | def disable_route(value=False): 19 | def wrapper(fn): 20 | @wraps(fn) 21 | def decorator(*args, **kwargs): 22 | print(value) 23 | if value.lower() in ["true", "yes", "y"]: 24 | return fn(*args, **kwargs) 25 | else: 26 | return jsonify({'error': 'Route disabled', 'message': 'This route has been disabled by the administrator.'}), 403 27 | return decorator 28 | return wrapper -------------------------------------------------------------------------------- /api/auth/models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask_marshmallow import Marshmallow, fields 3 | import uuid 4 | from werkzeug.security import generate_password_hash, check_password_hash 5 | from api.models import db, ma 6 | 7 | class Verification(db.Model): 8 | __tablename__ = "verification" 9 | id = db.Column(db.Integer, primary_key=True) 10 | user_id = db.Column(db.Integer, db.ForeignKey("users.id")) 11 | status = db.Column(db.String, default="unverified") 12 | code = db.Column(db.String, nullable=True) 13 | code_valid_until = db.Column(db.DateTime, nullable=True) 14 | 15 | def save_to_db(self): 16 | db.session.add(self) 17 | db.session.commit() 18 | 19 | class VerificationSchema(ma.SQLAlchemyAutoSchema): 20 | class Meta: 21 | model = Verification 22 | fields = ("status",) 23 | 24 | 25 | class User(db.Model): 26 | __tablename__ = 'users' 27 | id = db.Column(db.Integer, primary_key=True) 28 | email = db.Column(db.String, nullable=False, unique=True) 29 | name = db.Column(db.String, nullable=False) 30 | password = db.Column(db.String, nullable=False) 31 | role = db.Column(db.String, default="user") 32 | status = db.Column(db.String, default="active") 33 | verification = db.relationship("Verification", uselist=False, backref="verification") 34 | 35 | 36 | def save_to_db(self): 37 | db.session.add(self) 38 | db.session.commit() 39 | 40 | @classmethod 41 | def find_by_email(cls, email): 42 | return cls.query.filter_by(email=email).first() 43 | 44 | @staticmethod 45 | def generate_hash(password): 46 | return generate_password_hash(password) 47 | 48 | @staticmethod 49 | def verify_hash(password, hash): 50 | return check_password_hash(hash, password) 51 | 52 | class UserSchema(ma.SQLAlchemyAutoSchema): 53 | verification = ma.Nested(VerificationSchema()) 54 | class Meta: 55 | model = User 56 | fields = ("id", "name", "email", "role", "status", "verification") 57 | 58 | class RevokedTokenModel(db.Model): 59 | __tablename__ = 'revoked_tokens' 60 | 61 | id = db.Column(db.Integer, primary_key=True) 62 | jti = db.Column(db.String(120)) 63 | 64 | def add(self): 65 | db.session.add(self) 66 | db.session.commit() 67 | 68 | @classmethod 69 | def is_jti_blacklisted(cls, jti): 70 | query = cls.query.filter_by(jti=jti).first() 71 | return bool(query) -------------------------------------------------------------------------------- /api/auth/user_route.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | from api.auth.models import User, UserSchema 3 | from api.auth.decorators import require_role 4 | from flask_jwt_extended import jwt_required, get_jwt_identity 5 | 6 | user_endpoint = Blueprint('user_endpoint', __name__) 7 | 8 | @user_endpoint.route("/v1/users/me", methods=["GET"]) 9 | @jwt_required() 10 | def get_logged_in_user(): 11 | user_schema = UserSchema(many=False) 12 | user = User.query.filter_by(email=get_jwt_identity()).first() 13 | return jsonify(user_schema.dump(user)) 14 | 15 | 16 | @user_endpoint.route('/v1/users', methods=["GET"]) 17 | @require_role("internal_admin") 18 | def get_all_users(): 19 | user_schema = UserSchema(many=True) 20 | users = User.query.all() 21 | return jsonify(user_schema.dump(users)) -------------------------------------------------------------------------------- /api/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | class Config: 4 | CSRF_ENABLED = True 5 | SECRET_KEY = os.environ.get("AUTH_SECRET_KEY", "this-really-needs-to-be-changed") 6 | SQLALCHEMY_TRACK_MODIFICATIONS = False 7 | SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "postgresql://admin:password@localhost/booklogr") 8 | SWAGGER = { 9 | "openapi": "3.0.0", 10 | "info": { 11 | "title": "BookLogr API", 12 | "description": "API for accessing BookLogr", 13 | "contact": { 14 | "url": "https://github.com/mozzo1000/booklogr", 15 | }, 16 | "version": "1.0.0" 17 | }, 18 | "components": { 19 | "securitySchemes": { 20 | "bearerAuth": { 21 | "type": "http", 22 | "scheme": "bearer", 23 | "bearerFormat": "JWT" # optional, arbitrary value for documentation purposes 24 | } 25 | } 26 | 27 | }, 28 | "specs_route": "/docs" 29 | } -------------------------------------------------------------------------------- /api/decorators.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, request 2 | from functools import wraps 3 | 4 | def required_params(*args): 5 | """Decorator factory to check request data for POST requests and return 6 | an error if required parameters are missing.""" 7 | required = list(args) 8 | 9 | def decorator(fn): 10 | """Decorator that checks for the required parameters""" 11 | 12 | @wraps(fn) 13 | def wrapper(*args, **kwargs): 14 | missing = [r for r in required if r not in request.get_json()] 15 | if missing: 16 | response = { 17 | "status": "error", 18 | "message": "Request JSON is missing some required params", 19 | "missing": missing 20 | } 21 | return jsonify(response), 400 22 | return fn(*args, **kwargs) 23 | return wrapper 24 | return decorator -------------------------------------------------------------------------------- /api/models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask_marshmallow import Marshmallow, fields 3 | import uuid 4 | from werkzeug.security import generate_password_hash, check_password_hash 5 | from marshmallow import post_dump 6 | db = SQLAlchemy() 7 | ma = Marshmallow() 8 | 9 | class Files(db.Model): 10 | __tablename__ = "files" 11 | id = db.Column(db.Integer, primary_key=True) 12 | filename = db.Column(db.String) 13 | owner_id = db.Column(db.Integer) 14 | created_at = db.Column(db.DateTime, server_default=db.func.now()) 15 | 16 | class FilesSchema(ma.SQLAlchemyAutoSchema): 17 | class Meta: 18 | model = Files 19 | fields = ("id", "filename", "created_at",) 20 | 21 | class Tasks(db.Model): 22 | __tablename__ = "tasks" 23 | id = db.Column(db.Integer, primary_key=True) 24 | type = db.Column(db.String) 25 | data = db.Column(db.String) 26 | status = db.Column(db.String, default="fresh") 27 | worker = db.Column(db.String, nullable=True) 28 | created_on = db.Column(db.DateTime, server_default=db.func.now()) 29 | updated_on = db.Column(db.DateTime, nullable=True) 30 | created_by = db.Column(db.Integer) 31 | 32 | def save_to_db(self): 33 | db.session.add(self) 34 | db.session.commit() 35 | 36 | class TasksSchema(ma.SQLAlchemyAutoSchema): 37 | class Meta: 38 | model = Tasks 39 | 40 | 41 | class Notes(db.Model): 42 | __tablename__ = "notes" 43 | id = db.Column(db.Integer, primary_key=True) 44 | book_id = db.Column(db.Integer, db.ForeignKey("books.id")) 45 | content = db.Column(db.String, nullable=False) 46 | quote_page = db.Column(db.Integer, nullable=True) 47 | visibility = db.Column(db.String, default="hidden") 48 | created_on = db.Column(db.DateTime, server_default=db.func.now()) 49 | 50 | def save_to_db(self): 51 | db.session.add(self) 52 | db.session.commit() 53 | 54 | def delete(self): 55 | db.session.delete(self) 56 | db.session.commit() 57 | 58 | class NotesSchema(ma.SQLAlchemyAutoSchema): 59 | class Meta: 60 | model = Notes 61 | 62 | class NotesPublicOnlySchema(ma.SQLAlchemyAutoSchema): 63 | class Meta: 64 | model = Notes 65 | 66 | SKIP_VALUES = set([None]) 67 | 68 | @post_dump 69 | def remove_hidden_notes(self, data, **kwargs): 70 | if data["visibility"] == "hidden": 71 | return 72 | else: 73 | return { 74 | key: value for key, value in data.items() 75 | if value not in self.SKIP_VALUES 76 | } 77 | 78 | 79 | class Profile(db.Model): 80 | __tablename__ = "profiles" 81 | id = db.Column(db.Integer, primary_key=True) 82 | display_name = db.Column(db.String, unique=True) 83 | visibility = db.Column(db.String, default="hidden") 84 | owner_id = db.Column(db.Integer, unique=True) 85 | books = db.relationship("Books", backref='profiles') 86 | 87 | def save_to_db(self): 88 | db.session.add(self) 89 | db.session.commit() 90 | 91 | class Books(db.Model): 92 | __tablename__ = "books" 93 | id = db.Column(db.Integer, primary_key=True) 94 | title = db.Column(db.String) 95 | isbn = db.Column(db.String) 96 | description = db.Column(db.String) 97 | author = db.Column(db.String) 98 | reading_status = db.Column(db.String) 99 | current_page = db.Column(db.Integer) 100 | total_pages = db.Column(db.Integer) 101 | rating = db.Column(db.Numeric(precision=3, scale=2), nullable=True) 102 | owner_id = db.Column(db.Integer, db.ForeignKey("profiles.owner_id")) 103 | notes = db.relationship("Notes", backref="books") 104 | 105 | def save_to_db(self): 106 | db.session.add(self) 107 | db.session.commit() 108 | 109 | def delete(self): 110 | db.session.delete(self) 111 | db.session.commit() 112 | 113 | class BooksSchema(ma.SQLAlchemyAutoSchema): 114 | num_notes = ma.Method("get_num_notes") 115 | class Meta: 116 | model = Books 117 | 118 | def get_num_notes(self, obj): 119 | query = Notes.query.filter(Notes.book_id==obj.id).count() 120 | if query: 121 | return query 122 | else: 123 | return 0 124 | 125 | class BooksPublicOnlySchema(ma.SQLAlchemyAutoSchema): 126 | notes = ma.Nested(NotesPublicOnlySchema(many=True)) 127 | num_notes = ma.Method("get_num_notes") 128 | class Meta: 129 | model = Books 130 | 131 | def get_num_notes(self, obj): 132 | query = Notes.query.filter(Notes.book_id==obj.id, Notes.visibility=="public").count() 133 | if query: 134 | return query 135 | else: 136 | return 0 137 | 138 | class ProfileSchema(ma.SQLAlchemyAutoSchema): 139 | books = ma.List(ma.Nested(BooksPublicOnlySchema(only=("author", "description", "current_page", "total_pages", "reading_status", "title", "isbn", "rating", "notes", "num_notes")))) 140 | num_books_read = ma.Method("get_num_books_read") 141 | num_books_reading = ma.Method("get_num_books_currently_reading") 142 | num_books_tbr = ma.Method("get_num_books_to_be_read") 143 | 144 | 145 | def get_num_books_read(self, obj): 146 | query = Books.query.filter(Books.owner_id==obj.owner_id, Books.reading_status=="Read").count() 147 | if query: 148 | return query 149 | else: 150 | return None 151 | 152 | def get_num_books_currently_reading(self, obj): 153 | query = Books.query.filter(Books.owner_id==obj.owner_id, Books.reading_status=="Currently reading").count() 154 | if query: 155 | return query 156 | else: 157 | return None 158 | 159 | def get_num_books_to_be_read(self, obj): 160 | query = Books.query.filter(Books.owner_id==obj.owner_id, Books.reading_status=="To be read").count() 161 | if query: 162 | return query 163 | else: 164 | return None 165 | 166 | class Meta: 167 | model = Profile() 168 | fields = ("id", "display_name", "visibility", "books", "num_books_read", "num_books_reading", "num_books_tbr",) 169 | 170 | 171 | class UserSettings(db.Model): 172 | __tablename__ = "user_settings" 173 | id = db.Column(db.Integer, primary_key=True) 174 | owner_id = db.Column(db.Integer, unique=True) 175 | send_book_events = db.Column(db.Boolean, default=False) 176 | mastodon_url = db.Column(db.String, nullable=True) 177 | mastodon_access_token = db.Column(db.String, nullable=True) 178 | created_at = db.Column(db.DateTime, server_default=db.func.now()) 179 | updated_on = db.Column(db.DateTime, nullable=True) 180 | 181 | def save_to_db(self): 182 | db.session.add(self) 183 | db.session.commit() 184 | 185 | class UserSettingsSchema(ma.SQLAlchemyAutoSchema): 186 | class Meta: 187 | model = UserSettings -------------------------------------------------------------------------------- /api/routes/files.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, send_from_directory, jsonify 2 | from flask_jwt_extended import jwt_required, get_jwt 3 | from api.models import Files, FilesSchema 4 | import os 5 | 6 | files_endpoint = Blueprint('files', __name__) 7 | 8 | @files_endpoint.route("/v1/files/", methods=["GET"]) 9 | @jwt_required() 10 | def download_file(filename): 11 | """ 12 | Download file 13 | --- 14 | tags: 15 | - Files 16 | parameters: 17 | - name: filename 18 | in: path 19 | type: string 20 | required: true 21 | security: 22 | - bearerAuth: [] 23 | responses: 24 | 200: 25 | description: Returns file. 26 | 500: 27 | description: Unknown error occurred. 28 | """ 29 | claim_id = get_jwt()["id"] 30 | file = Files.query.filter(Files.filename==filename, Files.owner_id==claim_id).first() 31 | 32 | if file: 33 | return send_from_directory(os.getenv("EXPORT_FOLDER"), filename, as_attachment=True) 34 | else: 35 | return jsonify({ 36 | "error": "Unkown error", 37 | "message": "Unkown error occurred" 38 | }), 500 39 | 40 | 41 | 42 | @files_endpoint.route("/v1/files", methods=["GET"]) 43 | @jwt_required() 44 | def get_files(): 45 | """ 46 | Get list of files 47 | --- 48 | tags: 49 | - Files 50 | security: 51 | - bearerAuth: [] 52 | responses: 53 | 200: 54 | description: Returns list of files. 55 | 404: 56 | description: No files could be found. 57 | """ 58 | claim_id = get_jwt()["id"] 59 | file_schema = FilesSchema(many=True) 60 | files = Files.query.filter(Files.owner_id==claim_id).order_by(Files.created_at.desc()).all() 61 | 62 | if files: 63 | return file_schema.dump(files) 64 | else: 65 | return jsonify({ 66 | "error": "Not found", 67 | "message": "No files found" 68 | }), 404 69 | 70 | -------------------------------------------------------------------------------- /api/routes/notes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from flask_jwt_extended import jwt_required, get_jwt 3 | from api.models import Notes, NotesSchema, Books 4 | 5 | notes_endpoint = Blueprint('notes', __name__) 6 | 7 | @notes_endpoint.route("/v1/notes/", methods=["DELETE"]) 8 | @jwt_required() 9 | def remove_note(id): 10 | """ 11 | Delete note 12 | --- 13 | tags: 14 | - Notes 15 | parameters: 16 | - name: id 17 | in: path 18 | type: integer 19 | required: true 20 | security: 21 | - bearerAuth: [] 22 | responses: 23 | 200: 24 | description: Note removed successfully. 25 | 26 | 404: 27 | description: No note found. 28 | """ 29 | claim_id = get_jwt()["id"] 30 | note = Notes.query.join(Books, Books.id==Notes.book_id).filter(Books.owner_id==claim_id, Notes.id==id).first() 31 | if note: 32 | note.delete() 33 | return jsonify({'message': 'Note removed successfully'}), 200 34 | else: 35 | return jsonify({ 36 | "error": "Not found", 37 | "message": f"No note with ID: {id} was found" 38 | }), 404 39 | 40 | 41 | @notes_endpoint.route("/v1/notes/", methods=["PATCH"]) 42 | @jwt_required() 43 | def edit_note(id): 44 | """ 45 | Edit note 46 | --- 47 | tags: 48 | - Notes 49 | parameters: 50 | - name: id 51 | in: path 52 | type: integer 53 | required: true 54 | - name: visibility 55 | in: body 56 | type: string 57 | required: true 58 | security: 59 | - bearerAuth: [] 60 | responses: 61 | 200: 62 | description: Note changed sucessfully. 63 | 64 | 500: 65 | description: Unknown error. 66 | """ 67 | claim_id = get_jwt()["id"] 68 | note = Notes.query.join(Books, Books.id==Notes.book_id).filter(Books.owner_id==claim_id, Notes.id==id).first() 69 | if request.json: 70 | if "visibility" in request.json: 71 | note.visibility = request.json["visibility"] 72 | else: 73 | return jsonify({ 74 | "error": "Bad request", 75 | "message": "Received no json" 76 | }), 400 77 | try: 78 | note.save_to_db() 79 | return jsonify({'message': 'Note changed sucessfully'}), 200 80 | except: 81 | return jsonify({ 82 | "error": "Unkown error", 83 | "message": "Unkown error occurred" 84 | }), 500 -------------------------------------------------------------------------------- /api/routes/profiles.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from flask_jwt_extended import jwt_required, get_jwt 3 | from api.models import Profile, ProfileSchema, Notes, Books, UserSettings 4 | from api.decorators import required_params 5 | 6 | profiles_endpoint = Blueprint('profiles', __name__) 7 | 8 | @profiles_endpoint.route("/v1/profiles/", methods=["GET"]) 9 | def get_profile(display_name): 10 | """ 11 | Get profile by name 12 | --- 13 | tags: 14 | - Profiles 15 | parameters: 16 | - name: display_name 17 | in: path 18 | type: string 19 | required: true 20 | responses: 21 | 200: 22 | description: Information about the profile. 23 | 404: 24 | description: Profile not found. 25 | """ 26 | profile_schema = ProfileSchema() 27 | 28 | profile = Profile.query.join(Profile.books).join(Books.notes).filter(Profile.display_name==display_name, Profile.visibility=="public").first() 29 | if profile: 30 | return jsonify(profile_schema.dump(profile)) 31 | 32 | else: 33 | return jsonify({ 34 | "error": "Not found", 35 | "message": "No profile found" 36 | }), 404 37 | 38 | @profiles_endpoint.route("/v1/profiles", methods=["GET"]) 39 | @jwt_required() 40 | def get_profile_by_logged_in_id(): 41 | """ 42 | Get profile of the logged in user 43 | --- 44 | tags: 45 | - Profiles 46 | security: 47 | - bearerAuth: [] 48 | responses: 49 | 200: 50 | description: Information about the profile of the logged in user. 51 | 404: 52 | description: No profile found. 53 | """ 54 | claim_id = get_jwt()["id"] 55 | profile_schema = ProfileSchema() 56 | 57 | profile = Profile.query.filter(Profile.owner_id==claim_id).first() 58 | if profile: 59 | return jsonify(profile_schema.dump(profile)) 60 | 61 | else: 62 | return jsonify({ 63 | "error": "Not found", 64 | "message": "You have not created a profile yet" 65 | }), 404 66 | 67 | @required_params("display_name") 68 | @profiles_endpoint.route("/v1/profiles", methods=["POST"]) 69 | @jwt_required() 70 | def create_profile(): 71 | """ 72 | Create profile for logged in user 73 | --- 74 | tags: 75 | - Profiles 76 | parameters: 77 | - name: display_name 78 | in: body 79 | type: string 80 | required: true 81 | - name: visibility 82 | in: body 83 | type: string 84 | default: hidden 85 | required: false 86 | security: 87 | - bearerAuth: [] 88 | responses: 89 | 200: 90 | description: Profile created. 91 | 92 | 409: 93 | description: Profile already exists. 94 | """ 95 | claim_id = get_jwt()["id"] 96 | 97 | profile = Profile.query.filter(Profile.owner_id==claim_id).first() 98 | if profile: 99 | return jsonify({ 100 | "error": "Conflict", 101 | "message": "Profile already exists" 102 | }), 409 103 | 104 | 105 | else: 106 | visibility = "hidden" 107 | if "visibility" in request.json: 108 | if "hidden" or "public" in request.json["visibility"]: 109 | visibility = request.json["visibility"] 110 | new_profile = Profile(owner_id=claim_id, display_name=request.json["display_name"], visibility=visibility) 111 | new_user_settings = UserSettings(owner_id=claim_id) 112 | new_profile.save_to_db() 113 | new_user_settings.save_to_db() 114 | return jsonify({'message': 'Profile created'}), 200 115 | 116 | @profiles_endpoint.route("/v1/profiles", methods=["PATCH"]) 117 | @jwt_required() 118 | def edit_profile(): 119 | """ 120 | Edit profile 121 | --- 122 | tags: 123 | - Profiles 124 | parameters: 125 | - name: display_name 126 | in: body 127 | type: string 128 | required: false 129 | - name: visibility 130 | in: body 131 | type: string 132 | required: false 133 | security: 134 | - bearerAuth: [] 135 | responses: 136 | 200: 137 | description: Profile updated. 138 | 404: 139 | description: Profile not found. 140 | """ 141 | claim_id = get_jwt()["id"] 142 | 143 | profile = Profile.query.filter(Profile.owner_id==claim_id).first() 144 | if profile: 145 | if request.json: 146 | if "display_name" in request.json: 147 | profile.display_name = request.json["display_name"] 148 | if "visibility" in request.json: 149 | if "hidden" or "public" in request.json["visibility"]: 150 | profile.visibility = request.json["visibility"] 151 | profile.save_to_db() 152 | return jsonify({'message': 'Profile updated'}), 200 153 | else: 154 | return jsonify({ 155 | "error": "Bad request", 156 | "message": "display_name and/or visibility not given" 157 | }), 40 158 | 159 | else: 160 | return jsonify({ 161 | "error": "Not found", 162 | "message": "No profile was found." 163 | }), 404 -------------------------------------------------------------------------------- /api/routes/settings.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt 3 | from api.models import UserSettings, UserSettingsSchema 4 | from api.decorators import required_params 5 | import time 6 | 7 | settings_endpoint = Blueprint('settings', __name__) 8 | 9 | @settings_endpoint.route("/v1/settings", methods=["GET"]) 10 | @jwt_required() 11 | def get_settings(): 12 | claim_id = get_jwt()["id"] 13 | user_settings_schema = UserSettingsSchema(many=False) 14 | user_settings = UserSettings.query.filter(UserSettings.owner_id==claim_id).first() 15 | if user_settings: 16 | return jsonify(user_settings_schema.dump(user_settings)) 17 | else: 18 | return jsonify({ 19 | "error": "Not found", 20 | "message": "User settings not found" 21 | }), 404 22 | 23 | @settings_endpoint.route("/v1/settings", methods=["PATCH"]) 24 | @jwt_required() 25 | def edit_settings(): 26 | claim_id = get_jwt()["id"] 27 | 28 | user_settings = UserSettings.query.filter(UserSettings.owner_id==claim_id).first() 29 | if user_settings: 30 | if request.json: 31 | if "send_book_events" in request.json: 32 | user_settings.send_book_events = request.json["send_book_events"] 33 | if "mastodon_url" in request.json: 34 | user_settings.mastodon_url = request.json["mastodon_url"] 35 | if "mastodon_access_token" in request.json: 36 | user_settings.mastodon_access_token = request.json["mastodon_access_token"] 37 | 38 | user_settings.updated_on = time.strftime('%Y-%m-%d %H:%M:%S') 39 | user_settings.save_to_db() 40 | return jsonify({'message': 'User settings updated'}), 200 41 | else: 42 | return jsonify({ 43 | "error": "Bad request", 44 | "message": "send_book_events, mastodon_url and/or mastodon_access_token not given." 45 | }), 40 46 | else: 47 | return jsonify({ 48 | "error": "Not found", 49 | "message": "User settings not found." 50 | }), 404 -------------------------------------------------------------------------------- /api/routes/tasks.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from flask_jwt_extended import jwt_required, get_jwt 3 | from api.models import Tasks, TasksSchema, db 4 | from api.decorators import required_params 5 | from sqlalchemy import text 6 | import time 7 | 8 | tasks_endpoint = Blueprint('tasks', __name__) 9 | 10 | def _notify_workers(id): 11 | db.session.execute(text(f"NOTIFY task_created, '{id}';")) 12 | db.session.commit() 13 | 14 | def _create_task(type, data, created_by): 15 | new_task = Tasks(type=type, data=data, created_by=created_by) 16 | new_task.save_to_db() 17 | _notify_workers(new_task.id) 18 | return new_task 19 | 20 | 21 | @tasks_endpoint.route("/v1/tasks/", methods=["GET"]) 22 | @jwt_required() 23 | def get_task(id): 24 | """ 25 | Get tasks 26 | --- 27 | tags: 28 | - Tasks 29 | parameters: 30 | - name: id 31 | in: path 32 | type: integer 33 | required: true 34 | security: 35 | - bearerAuth: [] 36 | responses: 37 | 200: 38 | description: Returns information about the task. 39 | 404: 40 | description: Task could not be found. 41 | """ 42 | claim = get_jwt()["id"] 43 | task_schema = TasksSchema() 44 | 45 | task = Tasks.query.filter(Tasks.id==id, Tasks.created_by==claim).first() 46 | if task: 47 | return jsonify(task_schema.dump(task)) 48 | 49 | else: 50 | return jsonify({ 51 | "error": "Not found", 52 | "message": "No task found" 53 | }), 404 54 | 55 | @tasks_endpoint.route("/v1/tasks", methods=["POST"]) 56 | @jwt_required() 57 | @required_params("type", "data") 58 | def create_task(): 59 | """ 60 | Create task 61 | --- 62 | tags: 63 | - Tasks 64 | parameters: 65 | - name: type 66 | in: body 67 | type: string 68 | required: true 69 | - name: data 70 | in: body 71 | type: string 72 | required: true 73 | security: 74 | - bearerAuth: [] 75 | responses: 76 | 200: 77 | description: Task created. 78 | """ 79 | claim_id = get_jwt()["id"] 80 | new_task =_create_task(str(request.json["type"]), str(request.json["data"]), claim_id) 81 | 82 | return jsonify({'message': 'Task created.', "task_id": new_task.id}), 200 83 | 84 | 85 | @tasks_endpoint.route("/v1/tasks//retry", methods=["POST"]) 86 | @jwt_required() 87 | def retry_task(id): 88 | """ 89 | Create task 90 | --- 91 | tags: 92 | - Tasks 93 | parameters: 94 | - name: id 95 | in: path 96 | type: integer 97 | required: true 98 | security: 99 | - bearerAuth: [] 100 | responses: 101 | 200: 102 | description: Task set to be retried. 103 | 404: 104 | description: Could not find task. 105 | """ 106 | task = Tasks.query.filter(id==id).first() 107 | 108 | if task: 109 | task.status = "fresh" 110 | task.updated_on = time.strftime('%Y-%m-%d %H:%M:%S') 111 | _notify_workers(task.id) 112 | return jsonify({"message": "Task set to be retried."}) 113 | 114 | else: 115 | return jsonify({ 116 | "error": "Not found", 117 | "message": "No task found" 118 | }), 404 -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 15 | 19 | 27 | 31 | 32 | 36 | 38 | 43 | 47 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /assets/mastodon-enable-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/d39ffb22bc646e2665c8b2946798bece0d3ef78f/assets/mastodon-enable-share.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | services: 3 | booklogr-db: 4 | container_name: "booklogr-db" 5 | image: "postgres" # use latest official postgres version 6 | ports: 7 | - 5432:5432 8 | restart: always 9 | healthcheck: 10 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 11 | interval: 10s 12 | timeout: 5s 13 | retries: 5 14 | env_file: 15 | - .env 16 | volumes: 17 | - ./booklogr:/var/lib/postgresql/data # persist data even if container shuts down 18 | 19 | booklogr-api: 20 | container_name: "booklogr-api" 21 | image: mozzo/booklogr:v1.3.0 22 | depends_on: 23 | booklogr-db: 24 | condition: service_healthy 25 | env_file: 26 | - .env 27 | ports: 28 | - 5000:5000 29 | 30 | auth-api: 31 | container_name: "auth-server-api" 32 | image: mozzo/auth-server:1.1.1 33 | restart: always 34 | volumes: 35 | - ./auth_db_vol:/app/instance 36 | environment: 37 | DATABASE_URL: "sqlite:///users.db" 38 | env_file: 39 | - .env 40 | ports: 41 | - 5001:5000 42 | 43 | booklogr-web: 44 | container_name: "booklogr-web" 45 | image: mozzo/booklogr-web:v1.3.0 46 | environment: 47 | - BL_API_ENDPOINT=http://localhost:5000/ # CHANGE THIS TO POINT TO THE EXTERNAL ADRESS THE USER CAN ACCESS 48 | - BL_AUTH_ENDPOINT=http://localhost:5001 # CHANGE THIS TO POINT TO THE EXTERNAL ADRESS THE USER CAN ACCESS 49 | - BL_GOOGLE_ID=XXX.apps.googleusercontent.com # CHANGE THIS TO YOUR OWN GOOGLE ID 50 | - BL_DISABLE_HOMEPAGE=true 51 | - BL_DEMO_MODE=false 52 | ports: 53 | - 5150:80 54 | 55 | booklogr-worker: 56 | container_name: "booklogr-worker" 57 | image: mozzo/booklogr-worker:v1.3.0 58 | depends_on: 59 | booklogr-db: 60 | condition: service_healthy 61 | env_file: 62 | - .env 63 | 64 | volumes: 65 | booklogr: 66 | auth_db_vol: -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Running database migration.." 4 | python -m flask db upgrade 5 | echo "Starting gunicorn.." 6 | exec gunicorn -w 4 -b :5000 api.app:app -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.config import fileConfig 3 | 4 | from flask import current_app 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | logger = logging.getLogger('alembic.env') 16 | 17 | 18 | def get_engine(): 19 | try: 20 | # this works with Flask-SQLAlchemy<3 and Alchemical 21 | return current_app.extensions['migrate'].db.get_engine() 22 | except (TypeError, AttributeError): 23 | # this works with Flask-SQLAlchemy>=3 24 | return current_app.extensions['migrate'].db.engine 25 | 26 | 27 | def get_engine_url(): 28 | try: 29 | return get_engine().url.render_as_string(hide_password=False).replace( 30 | '%', '%%') 31 | except AttributeError: 32 | return str(get_engine().url).replace('%', '%%') 33 | 34 | 35 | # add your model's MetaData object here 36 | # for 'autogenerate' support 37 | # from myapp import mymodel 38 | # target_metadata = mymodel.Base.metadata 39 | config.set_main_option('sqlalchemy.url', get_engine_url()) 40 | target_db = current_app.extensions['migrate'].db 41 | 42 | # other values from the config, defined by the needs of env.py, 43 | # can be acquired: 44 | # my_important_option = config.get_main_option("my_important_option") 45 | # ... etc. 46 | 47 | 48 | def get_metadata(): 49 | if hasattr(target_db, 'metadatas'): 50 | return target_db.metadatas[None] 51 | return target_db.metadata 52 | 53 | 54 | def run_migrations_offline(): 55 | """Run migrations in 'offline' mode. 56 | 57 | This configures the context with just a URL 58 | and not an Engine, though an Engine is acceptable 59 | here as well. By skipping the Engine creation 60 | we don't even need a DBAPI to be available. 61 | 62 | Calls to context.execute() here emit the given string to the 63 | script output. 64 | 65 | """ 66 | url = config.get_main_option("sqlalchemy.url") 67 | context.configure( 68 | url=url, target_metadata=get_metadata(), literal_binds=True 69 | ) 70 | 71 | with context.begin_transaction(): 72 | context.run_migrations() 73 | 74 | 75 | def run_migrations_online(): 76 | """Run migrations in 'online' mode. 77 | 78 | In this scenario we need to create an Engine 79 | and associate a connection with the context. 80 | 81 | """ 82 | 83 | # this callback is used to prevent an auto-migration from being generated 84 | # when there are no changes to the schema 85 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 86 | def process_revision_directives(context, revision, directives): 87 | if getattr(config.cmd_opts, 'autogenerate', False): 88 | script = directives[0] 89 | if script.upgrade_ops.is_empty(): 90 | directives[:] = [] 91 | logger.info('No changes in schema detected.') 92 | 93 | conf_args = current_app.extensions['migrate'].configure_args 94 | if conf_args.get("process_revision_directives") is None: 95 | conf_args["process_revision_directives"] = process_revision_directives 96 | 97 | connectable = get_engine() 98 | 99 | with connectable.connect() as connection: 100 | context.configure( 101 | connection=connection, 102 | target_metadata=get_metadata(), 103 | **conf_args 104 | ) 105 | 106 | with context.begin_transaction(): 107 | context.run_migrations() 108 | 109 | 110 | if context.is_offline_mode(): 111 | run_migrations_offline() 112 | else: 113 | run_migrations_online() 114 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/334c6f93b980_add_rating_column.py: -------------------------------------------------------------------------------- 1 | """Add rating column 2 | 3 | Revision ID: 334c6f93b980 4 | Revises: 4f342e92ab1f 5 | Create Date: 2024-07-15 17:46:55.556749 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '334c6f93b980' 14 | down_revision = '4f342e92ab1f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('books', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('rating', sa.Numeric(precision=3, scale=2), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('books', schema=None) as batch_op: 30 | batch_op.drop_column('rating') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/4f342e92ab1f_add_profiles_table.py: -------------------------------------------------------------------------------- 1 | """Add profiles table 2 | 3 | Revision ID: 4f342e92ab1f 4 | Revises: ae48d4b4f9d8 5 | Create Date: 2024-06-30 17:54:54.623967 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '4f342e92ab1f' 14 | down_revision = 'ae48d4b4f9d8' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('profiles', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('display_name', sa.String(), nullable=True), 24 | sa.Column('visibility', sa.String(), nullable=True), 25 | sa.Column('owner_id', sa.Integer(), nullable=True), 26 | sa.PrimaryKeyConstraint('id'), 27 | sa.UniqueConstraint('display_name'), 28 | sa.UniqueConstraint('owner_id') 29 | ) 30 | with op.batch_alter_table('books', schema=None) as batch_op: 31 | batch_op.create_foreign_key(None, 'profiles', ['owner_id'], ['owner_id']) 32 | 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | with op.batch_alter_table('books', schema=None) as batch_op: 39 | batch_op.add_column(sa.Column('profile_id', sa.INTEGER(), autoincrement=False, nullable=True)) 40 | batch_op.drop_constraint(None, type_='foreignkey') 41 | 42 | op.drop_table('profiles') 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /migrations/versions/5c9e5e91be8f_add_notes_table.py: -------------------------------------------------------------------------------- 1 | """Add notes table 2 | 3 | Revision ID: 5c9e5e91be8f 4 | Revises: 334c6f93b980 5 | Create Date: 2024-07-15 20:53:15.158373 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '5c9e5e91be8f' 14 | down_revision = '334c6f93b980' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('notes', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('book_id', sa.Integer(), nullable=True), 24 | sa.Column('content', sa.String(), nullable=False), 25 | sa.Column('visibility', sa.String(), nullable=True), 26 | sa.Column('created_on', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 27 | sa.ForeignKeyConstraint(['book_id'], ['books.id'], ), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_table('notes') 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /migrations/versions/82989182da4a_add_files_table.py: -------------------------------------------------------------------------------- 1 | """Add files table 2 | 3 | Revision ID: 82989182da4a 4 | Revises: 9232f4adc495 5 | Create Date: 2024-07-26 19:30:06.488079 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '82989182da4a' 14 | down_revision = '9232f4adc495' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('files', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('filename', sa.String(), nullable=True), 24 | sa.Column('owner_id', sa.Integer(), nullable=True), 25 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 26 | sa.PrimaryKeyConstraint('id') 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table('files') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/versions/89b6827408dd_add_user_settings_table.py: -------------------------------------------------------------------------------- 1 | """Add user settings table 2 | 3 | Revision ID: 89b6827408dd 4 | Revises: 82989182da4a 5 | Create Date: 2024-12-27 11:05:14.002607 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '89b6827408dd' 14 | down_revision = '82989182da4a' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user_settings', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('owner_id', sa.Integer(), nullable=True), 24 | sa.Column('send_book_events', sa.Boolean(), nullable=True), 25 | sa.Column('mastodon_url', sa.String(), nullable=True), 26 | sa.Column('mastodon_access_token', sa.String(), nullable=True), 27 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 28 | sa.Column('updated_on', sa.DateTime(), nullable=True), 29 | sa.PrimaryKeyConstraint('id'), 30 | sa.UniqueConstraint('owner_id') 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('user_settings') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /migrations/versions/8f8a46fec66b_add_auth_tables.py: -------------------------------------------------------------------------------- 1 | """Add auth tables 2 | 3 | Revision ID: 8f8a46fec66b 4 | Revises: c9e06189519a 5 | Create Date: 2025-01-27 19:53:55.888180 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '8f8a46fec66b' 14 | down_revision = 'c9e06189519a' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('revoked_tokens', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('jti', sa.String(length=120), nullable=True), 24 | sa.PrimaryKeyConstraint('id') 25 | ) 26 | op.create_table('users', 27 | sa.Column('id', sa.Integer(), nullable=False), 28 | sa.Column('email', sa.String(), nullable=False), 29 | sa.Column('name', sa.String(), nullable=False), 30 | sa.Column('password', sa.String(), nullable=False), 31 | sa.Column('role', sa.String(), nullable=True), 32 | sa.Column('status', sa.String(), nullable=True), 33 | sa.PrimaryKeyConstraint('id'), 34 | sa.UniqueConstraint('email') 35 | ) 36 | op.create_table('verification', 37 | sa.Column('id', sa.Integer(), nullable=False), 38 | sa.Column('user_id', sa.Integer(), nullable=True), 39 | sa.Column('status', sa.String(), nullable=True), 40 | sa.Column('code', sa.String(), nullable=True), 41 | sa.Column('code_valid_until', sa.DateTime(), nullable=True), 42 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), 43 | sa.PrimaryKeyConstraint('id') 44 | ) 45 | # ### end Alembic commands ### 46 | 47 | 48 | def downgrade(): 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_table('verification') 51 | op.drop_table('users') 52 | op.drop_table('revoked_tokens') 53 | # ### end Alembic commands ### 54 | -------------------------------------------------------------------------------- /migrations/versions/9232f4adc495_add_tasks_table.py: -------------------------------------------------------------------------------- 1 | """Add tasks table 2 | 3 | Revision ID: 9232f4adc495 4 | Revises: 5c9e5e91be8f 5 | Create Date: 2024-07-23 12:45:00.366358 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '9232f4adc495' 14 | down_revision = '5c9e5e91be8f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('tasks', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('type', sa.String(), nullable=True), 24 | sa.Column('data', sa.String(), nullable=True), 25 | sa.Column('status', sa.String(), nullable=True), 26 | sa.Column('worker', sa.String(), nullable=True), 27 | sa.Column('created_on', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 28 | sa.Column('updated_on', sa.DateTime(), nullable=True), 29 | sa.Column('created_by', sa.Integer(), nullable=True), 30 | sa.PrimaryKeyConstraint('id') 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('tasks') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /migrations/versions/ae48d4b4f9d8_add_owner_id_column.py: -------------------------------------------------------------------------------- 1 | """Add owner_id column 2 | 3 | Revision ID: ae48d4b4f9d8 4 | Revises: e60887c12d97 5 | Create Date: 2024-06-30 16:43:58.045264 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ae48d4b4f9d8' 14 | down_revision = 'e60887c12d97' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('books', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('owner_id', sa.Integer(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('books', schema=None) as batch_op: 30 | batch_op.drop_column('owner_id') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/c9e06189519a_add_quote_page_column_to_notes_table.py: -------------------------------------------------------------------------------- 1 | """Add quote_page column to notes table 2 | 3 | Revision ID: c9e06189519a 4 | Revises: 89b6827408dd 5 | Create Date: 2024-12-27 14:15:32.832448 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c9e06189519a' 14 | down_revision = '89b6827408dd' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('notes', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('quote_page', sa.Integer(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('notes', schema=None) as batch_op: 30 | batch_op.drop_column('quote_page') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/ce3cee57bdd9_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: ce3cee57bdd9 4 | Revises: 5 | Create Date: 2024-03-25 20:29:02.174949 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ce3cee57bdd9' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('books', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('title', sa.String(), nullable=True), 24 | sa.Column('isbn', sa.String(), nullable=True), 25 | sa.Column('description', sa.String(), nullable=True), 26 | sa.Column('reading_status', sa.String(), nullable=True), 27 | sa.Column('current_page', sa.Integer(), nullable=True), 28 | sa.Column('total_pages', sa.Integer(), nullable=True), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table('books') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /migrations/versions/e60887c12d97_add_author_column.py: -------------------------------------------------------------------------------- 1 | """Add author column 2 | 3 | Revision ID: e60887c12d97 4 | Revises: ce3cee57bdd9 5 | Create Date: 2024-03-29 15:18:50.118628 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e60887c12d97' 14 | down_revision = 'ce3cee57bdd9' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('books', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('author', sa.String(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('books', schema=None) as batch_op: 30 | batch_op.drop_column('author') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "booklogr-api" 3 | version = "1.0.0" 4 | description = "API for Booklogr" 5 | authors = ["Andreas Backström "] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | packages = [{include = "api"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | Flask = "3.0.3" 13 | python-dotenv = "1.0.1" 14 | Flask-JWT-Extended = "4.6.0" 15 | flask-cors = "5.0.0" 16 | psycopg2 = "2.9.9" 17 | flask-marshmallow = "1.2.1" 18 | flask-sqlalchemy = "3.1.1" 19 | marshmallow-sqlalchemy = "1.1.0" 20 | Flask-Migrate = "4.0.7" 21 | gunicorn = "23.0.0" 22 | flasgger = "0.9.7.1" 23 | requests = "^2.32.3" 24 | 25 | [tool.poetry.group.dev.dependencies] 26 | pytest = "7.4.3" 27 | 28 | [build-system] 29 | requires = ["poetry-core"] 30 | build-backend = "poetry.core.masonry.api" -------------------------------------------------------------------------------- /web/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .dockerignore 4 | 5 | # Build dependencies 6 | dist 7 | build 8 | node_modules 9 | 10 | # Environment (contains sensitive data) 11 | .env 12 | 13 | # Files not required for production 14 | .editorconfig 15 | Dockerfile 16 | README.md -------------------------------------------------------------------------------- /web/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_ENDPOINT="http://localhost:5000/" 2 | VITE_AUTH_API_URL="http://localhost:5001" 3 | VITE_GOOGLE_CLIENT_ID=XXX.apps.googleusercontent.com 4 | VITE_DISABLE_HOMEPAGE=false 5 | VITE_DEMO_MODE=false -------------------------------------------------------------------------------- /web/.env.production: -------------------------------------------------------------------------------- 1 | VITE_API_ENDPOINT=BL_API_ENDPOINT 2 | VITE_AUTH_API_URL=BL_AUTH_ENDPOINT 3 | VITE_GOOGLE_CLIENT_ID=BL_GOOGLE_ID 4 | VITE_DISABLE_HOMEPAGE=BL_DISABLE_HOMEPAGE 5 | VITE_DEMO_MODE=BL_DEMO_MODE -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react/jsx-no-target-blank': 'off', 16 | 'react-refresh/only-export-components': [ 17 | 'warn', 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build Image 2 | FROM node:22-alpine AS build 3 | RUN apk add git 4 | WORKDIR /app 5 | 6 | COPY package*.json ./ 7 | RUN npm install --force 8 | COPY . . 9 | RUN npm run build 10 | 11 | # Stage 2, use the compiled app, ready for production with Nginx 12 | FROM nginx:1.21.6-alpine 13 | COPY --from=build /app/dist /usr/share/nginx/html 14 | COPY /nginx-custom.conf /etc/nginx/conf.d/default.conf 15 | COPY inject-env.sh /docker-entrypoint.d/inject-env.sh 16 | RUN chmod +x /docker-entrypoint.d/inject-env.sh -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | BookLogr 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /web/inject-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | for i in $(env | grep BL_) 3 | do 4 | key=$(echo $i | cut -d '=' -f 1) 5 | value=$(echo $i | cut -d '=' -f 2-) 6 | echo $key=$value 7 | # sed All files 8 | # find /usr/share/nginx/html -type f -exec sed -i "s|${key}|${value}|g" '{}' + 9 | 10 | # sed JS and CSS only 11 | find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' + 12 | done -------------------------------------------------------------------------------- /web/nginx-custom.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | 5 | location / { 6 | root /usr/share/nginx/html; 7 | index index.html index.htm; 8 | try_files $uri $uri/ /index.html =404; 9 | } 10 | } -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@react-oauth/google": "^0.12.1", 14 | "axios": "1.7.9", 15 | "flowbite-react": "0.10.2", 16 | "framer-motion": "^11.15.0", 17 | "lodash": "^4.17.21", 18 | "react": "19.0.0", 19 | "react-confetti": "^6.1.0", 20 | "react-dom": "19.0.0", 21 | "react-helmet-async": "^2.0.5", 22 | "react-image": "^4.1.0", 23 | "react-loading-skeleton": "^3.5.0", 24 | "react-router-dom": "7.1.1" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "19.0.2", 28 | "@types/react-dom": "19.0.2", 29 | "@vitejs/plugin-react": "4.3.4", 30 | "autoprefixer": "^10.4.20", 31 | "eslint": "^9.17.0", 32 | "eslint-plugin-react": "^7.37.3", 33 | "eslint-plugin-react-hooks": "^5.1.0", 34 | "eslint-plugin-react-refresh": "^0.4.16", 35 | "flowbite-typography": "^1.0.5", 36 | "postcss": "^8.4.49", 37 | "tailwindcss": "3.4.17", 38 | "vite": "6.0.6" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/public/fallback-cover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | BOOK COVERNOT AVAILABLE 51 | -------------------------------------------------------------------------------- /web/public/feature_section_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/d39ffb22bc646e2665c8b2946798bece0d3ef78f/web/public/feature_section_01.png -------------------------------------------------------------------------------- /web/public/feature_section_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/d39ffb22bc646e2665c8b2946798bece0d3ef78f/web/public/feature_section_02.png -------------------------------------------------------------------------------- /web/public/feature_section_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/d39ffb22bc646e2665c8b2946798bece0d3ef78f/web/public/feature_section_03.png -------------------------------------------------------------------------------- /web/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 15 | 19 | 27 | 31 | 32 | 36 | 38 | 43 | 47 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /web/public/medal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 18 | 28 | 33 | 35 | 42 | 48 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /web/public/social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mozzo1000/booklogr/d39ffb22bc646e2665c8b2946798bece0d3ef78f/web/public/social-card.png -------------------------------------------------------------------------------- /web/public/wave.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 27 | 29 | 34 | 43 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /web/public/wave_02.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 38 | 40 | 45 | 54 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /web/src/AnimatedLayout.jsx: -------------------------------------------------------------------------------- 1 | import { motion } from "framer-motion"; 2 | 3 | const variants = { 4 | hidden: { opacity: 0 }, 5 | enter: { opacity: 1 }, 6 | exit: { opacity: 0 } 7 | } 8 | 9 | const AnimatedLayout = ({ children }) => { 10 | return ( 11 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | export default AnimatedLayout; -------------------------------------------------------------------------------- /web/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route, Navigate, useLocation, useNavigate} from "react-router-dom"; 2 | import BookDetails from "./pages/BookDetails"; 3 | import Library from "./pages/Library"; 4 | import Home from "./pages/Home"; 5 | import SearchBar from "./components/SearchBar"; 6 | import ToastContainer from "./toast/Container"; 7 | import NavigationMenu from "./components/Navbar" 8 | import Login from "./pages/Login"; 9 | import Profile from "./pages/Profile"; 10 | import Footer from "./components/Footer"; 11 | import Register from "./pages/Register"; 12 | import AuthService from "./services/auth.service"; 13 | import SidebarNav from "./components/SidebarNav"; 14 | import Verify from "./pages/Verify"; 15 | import Settings from "./pages/Settings"; 16 | import globalRouter from "./GlobalRouter"; 17 | import { AnimatePresence } from "framer-motion"; 18 | 19 | function PrivateRoute({ children }) { 20 | const auth = AuthService.getCurrentUser() 21 | return auth ? children : ; 22 | } 23 | 24 | function App() { 25 | const navigate = useNavigate(); 26 | globalRouter.navigate = navigate; 27 | 28 | let location = useLocation(); 29 | 30 | return ( 31 |
32 |
33 | {AuthService.getCurrentUser() && 34 |
35 | 36 |
37 | } 38 |
39 | 40 | {!AuthService.getCurrentUser() && 41 | location.pathname != "/library" && 42 | 43 | 44 | } 45 | 46 | 47 | 48 | } /> 49 | 50 | } /> 51 | } /> 52 | } /> 53 | } /> 54 | } /> 55 | 56 | } /> 57 | } /> 58 | } /> 59 | 60 | 61 | 62 | 63 |
64 |
65 |
66 | 67 |
68 | ) 69 | } 70 | 71 | export default App 72 | -------------------------------------------------------------------------------- /web/src/GlobalRouter.jsx: -------------------------------------------------------------------------------- 1 | 2 | import { Navigate } from "react-router-dom"; 3 | 4 | const globalRouter = { Navigate }; 5 | 6 | export default globalRouter; 7 | 8 | -------------------------------------------------------------------------------- /web/src/components/AccountTab.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Avatar, Button, Checkbox, Label, TextInput } from "flowbite-react"; 3 | import { RiMailLine } from "react-icons/ri"; 4 | 5 | function AccountTab() { 6 | const [disableSaveButton, setDisableSaveButton] = useState(true); 7 | return ( 8 |
9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 | 17 | 18 |
19 | 20 |
21 | 22 |
23 | 24 |
25 |
26 |

Email

27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 |

Change password

38 |
39 |
40 |
41 |
44 | 45 |
46 |
49 | 50 |
51 |
54 | 55 | 56 |
57 |
58 | 59 |
60 | ) 61 | } 62 | 63 | export default AccountTab -------------------------------------------------------------------------------- /web/src/components/ActionsBookLibraryButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Dropdown, Modal, Button } from 'flowbite-react' 3 | import BooksService from '../services/books.service'; 4 | import useToast from '../toast/useToast'; 5 | import { FaEllipsisVertical } from "react-icons/fa6"; 6 | import { RiDeleteBin6Line } from "react-icons/ri"; 7 | import { RiErrorWarningLine } from "react-icons/ri"; 8 | import NotesView from './NotesView'; 9 | import { RiStickyNoteLine } from "react-icons/ri"; 10 | import { RiBook2Line } from "react-icons/ri"; 11 | import { RiBookOpenLine } from "react-icons/ri"; 12 | import { RiBookmarkLine } from "react-icons/ri"; 13 | 14 | function ActionsBookLibraryButton(props) { 15 | const [status, setStatus] = useState(); 16 | const [removalConfModal, setRemovalConfModal] = useState(); 17 | const [openNotesModal, setOpenNotesModal] = useState(); 18 | const toast = useToast(4000); 19 | 20 | const changeStatus = (statusChangeTo) => { 21 | BooksService.edit(props.id, {status: statusChangeTo}).then( 22 | response => { 23 | toast("success", response.data.message); 24 | props.onSuccess(); 25 | }, 26 | error => { 27 | const resMessage = 28 | (error.response && 29 | error.response.data && 30 | error.response.data.message) || 31 | error.message || 32 | error.toString(); 33 | toast("error", resMessage); 34 | } 35 | ) 36 | } 37 | 38 | const clickDropItem = (stateStatus) => { 39 | setStatus(stateStatus); 40 | changeStatus(stateStatus); 41 | } 42 | 43 | const removeBook = () => { 44 | BooksService.remove(props.id).then( 45 | response => { 46 | toast("success", response.data.message); 47 | props.onSuccess(); 48 | }, 49 | error => { 50 | const resMessage = 51 | (error.response && 52 | error.response.data && 53 | error.response.data.message) || 54 | error.message || 55 | error.toString(); 56 | toast("error", resMessage); 57 | } 58 | ) 59 | setRemovalConfModal(false); 60 | 61 | } 62 | 63 | return ( 64 | <> 65 | }> 66 | 67 | Reading status 68 | 69 | (clickDropItem("Currently reading"))}>Currently reading 70 | (clickDropItem("To be read"))}>To be read 71 | (clickDropItem("Read"))}>Read 72 | 73 | 74 | setOpenNotesModal(true)}>Notes & Quotes 75 | setRemovalConfModal(true)}>Remove 76 | 77 | 78 | {/* REMOVE BOOK CONFIRMATION DIALOG */} 79 | setRemovalConfModal(false)} popup> 80 | 81 | 82 |
83 | 84 |

85 | Are you sure you want to remove this book? 86 |

87 |
88 | 91 | 94 |
95 |
96 |
97 |
98 | 99 | 100 | ) 101 | } 102 | 103 | ActionsBookLibraryButton.defaultProps = { 104 | allowNoteEditing: true, 105 | } 106 | 107 | export default ActionsBookLibraryButton -------------------------------------------------------------------------------- /web/src/components/AddToReadingListButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { Button, Modal, Label, TextInput, Select} from "flowbite-react"; 3 | import BooksService from '../services/books.service'; 4 | import useToast from '../toast/useToast'; 5 | 6 | function AddToReadingListButton(props) { 7 | const [openModal, setOpenModal] = useState(false); 8 | const [readingStatus, setReadingStatus] = useState(); 9 | const [currentPage, setCurrentPage] = useState(); 10 | const [totalPages, setTotalPages] = useState(); 11 | const toast = useToast(4000); 12 | 13 | const handleSave = () => { 14 | var arr = {} 15 | arr.title = props.data?.title; 16 | arr.isbn = props.isbn; 17 | arr.author = props.data?.author_name?.[0]; //Authors object from the API can have more than one object inside.. fix this later by flattening and getting a list of all authors names. 18 | if (props.data?.description) { 19 | arr.description = props.data?.description; 20 | } 21 | if (readingStatus) { 22 | arr.reading_status = readingStatus; 23 | } 24 | 25 | if (currentPage) { 26 | arr.current_page = currentPage; 27 | } 28 | 29 | if (totalPages) { 30 | arr.total_pages = totalPages; 31 | } 32 | 33 | BooksService.add(arr).then( 34 | response => { 35 | toast("success", response.data.message); 36 | setOpenModal(false); 37 | }, 38 | error => { 39 | const resMessage = 40 | (error.response && 41 | error.response.data && 42 | error.response.data.message) || 43 | error.message || 44 | error.toString(); 45 | toast("error", resMessage); 46 | } 47 | ) 48 | } 49 | 50 | useEffect(() => { 51 | setTotalPages(props.data?.number_of_pages) 52 | }, [props.data]) 53 | 54 | 55 | return ( 56 |
57 | 58 | setOpenModal(false)}> 59 | Add book to list 60 | 61 |
62 |

Book title: {props.data?.title}

63 |

ISBN: {props.isbn}

64 |
65 |
67 | 72 | 73 |
74 |
76 | setCurrentPage(e.target.value)} /> 77 | 78 |
79 |
81 | setTotalPages(e.target.value)} /> 82 |
83 |
84 | 85 | 86 | 89 | 90 |
91 |
92 | ) 93 | } 94 | 95 | export default AddToReadingListButton -------------------------------------------------------------------------------- /web/src/components/BookStatsCard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function BookStatsCard(props) { 4 | return ( 5 |
6 |
7 | {props.icon} 8 |
9 |
10 |

{props.number}

11 |
12 |
13 |

{props.text}

14 |
15 |
16 | ) 17 | } 18 | 19 | export default BookStatsCard -------------------------------------------------------------------------------- /web/src/components/CTA.jsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'flowbite-react' 2 | import React from 'react' 3 | import { Link } from 'react-router-dom' 4 | 5 | function CTA() { 6 | return ( 7 |
8 |
9 |
10 |

Start tracking your books today

11 | 12 |
13 |
14 |
15 | ) 16 | } 17 | 18 | export default CTA -------------------------------------------------------------------------------- /web/src/components/Data/DataTab.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import FileList from './FileList' 3 | import RequestData from './RequestData' 4 | 5 | function DataTab() { 6 | return ( 7 |
8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | 17 | 18 |
19 | ) 20 | } 21 | 22 | export default DataTab -------------------------------------------------------------------------------- /web/src/components/Data/FileList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { Table, Button } from "flowbite-react"; 3 | import FilesService from '../../services/files.service'; 4 | import useToast from '../../toast/useToast'; 5 | 6 | function FileList() { 7 | const [files, setFiles] = useState(); 8 | const toast = useToast(4000); 9 | 10 | useEffect(() => { 11 | FilesService.getAll().then( 12 | response => { 13 | setFiles(response.data); 14 | }, 15 | error => { 16 | if (error.response.status != 404) { 17 | const resMessage = 18 | (error.response && 19 | error.response.data && 20 | error.response.data.message) || 21 | error.message || 22 | error.toString(); 23 | console.log(error) 24 | toast("error", resMessage); 25 | } 26 | } 27 | ) 28 | }, []) 29 | 30 | const downloadFile = (filename) => { 31 | FilesService.get(filename).then( 32 | response => { 33 | // create file link in browser's memory 34 | const href = URL.createObjectURL(response.data); 35 | 36 | // create "a" HTML element with href to file & click 37 | const link = document.createElement('a'); 38 | link.href = href; 39 | link.setAttribute('download', filename); //or any other extension 40 | document.body.appendChild(link); 41 | link.click(); 42 | 43 | // clean up "a" element & remove ObjectURL 44 | document.body.removeChild(link); 45 | URL.revokeObjectURL(href); 46 | }, 47 | error => { 48 | const resMessage = 49 | (error.response && 50 | error.response.data && 51 | error.response.data.message) || 52 | error.message || 53 | error.toString(); 54 | toast("error", resMessage); 55 | } 56 | ) 57 | } 58 | 59 | return ( 60 |
61 |

Available exports

62 | 63 | 64 | Filename 65 | Created 66 | Action 67 | 68 | 69 | {files?.map((item) => { 70 | return ( 71 | 72 | {item.filename} 73 | {new Date(item.created_at).toLocaleDateString("en-US", {year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', hour12: false})} 74 | 75 | 76 | 77 | ) 78 | }) 79 | 80 | } 81 | 82 |
83 |
84 | ) 85 | } 86 | 87 | export default FileList -------------------------------------------------------------------------------- /web/src/components/Data/RequestData.jsx: -------------------------------------------------------------------------------- 1 | import { Card, Button, Label, Select} from 'flowbite-react' 2 | import React, {useState } from 'react' 3 | import TasksService from '../../services/tasks.service' 4 | import useToast from '../../toast/useToast'; 5 | 6 | function RequestData() { 7 | const [dataFormat, setDataFormat] = useState("csv") 8 | const toast = useToast(4000); 9 | 10 | const requestData = () => { 11 | let taskType; 12 | if (dataFormat == "csv") { 13 | taskType = "csv_export" 14 | }else if (dataFormat == "json") { 15 | taskType = "json_export" 16 | }else if (dataFormat == "html") { 17 | taskType = "html_export" 18 | } 19 | TasksService.create(taskType, {}).then( 20 | response => { 21 | toast("success", response.data.message); 22 | }, 23 | error => { 24 | const resMessage = 25 | (error.response && 26 | error.response.data && 27 | error.response.data.message) || 28 | error.message || 29 | error.toString(); 30 | toast("error", resMessage); 31 | } 32 | ) 33 | } 34 | 35 | return ( 36 | 37 |
38 |
39 |

Request data

40 |

You can request a copy of all your data. Once the request has finished, the data will be displayed in the "Available exports" table for you to download.

41 |
42 |
43 |
44 |
46 | 51 |
52 | 53 |
54 |
55 | ) 56 | } 57 | 58 | export default RequestData -------------------------------------------------------------------------------- /web/src/components/ESCIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function ESCIcon() { 4 | return ( 5 | 13 | 19 | 20 | 27 | 34 | 35 | 50 | 62 | ESC 63 | 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | export default ESCIcon; 71 | -------------------------------------------------------------------------------- /web/src/components/FeatureSection.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { RiOpenSourceLine } from "react-icons/ri"; 3 | import { RiServerLine } from "react-icons/ri"; 4 | function FeatureSection() { 5 | return ( 6 | <> 7 | {/* First section */} 8 |
9 |
10 | {/* First feature */} 11 |
12 | Library picture 13 |
14 |
15 |

Build your virtual library

16 |

Add your books to lists depending on if you have read them, are 17 | currently reading or want to read.

18 |
19 | 20 | {/* Second feature */} 21 |
22 |

Share your reading list

23 |

Let your friends see what kind of exciting book you are currently reading and have read in the past

24 |
25 |
26 | Library picture 27 |
28 | 29 | {/* Third feature */} 30 |
31 | Library picture 32 |
33 |
34 |

Look up your favorite book with ease

35 |

With search and catalog powered by OpenLibrary, there are millions of books in our catalog with more added each day.

36 |
37 |
38 |
39 | 40 | 41 | {/* Second section */} 42 |
43 |
44 |
45 |
46 | 47 |
48 |

Open Source

49 |

All code is fully open source and available on Github

50 |
51 | 52 |
53 |
54 | 55 |
56 |

Self-hosted by design

57 |

Run BookLogr on your own hardware and have complete control over your data

58 |
59 |
60 |
61 | 62 | ) 63 | } 64 | 65 | export default FeatureSection -------------------------------------------------------------------------------- /web/src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Footer as FFooter } from 'flowbite-react'; 3 | 4 | function Footer() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | export default Footer -------------------------------------------------------------------------------- /web/src/components/GoogleLoginButton.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import { useGoogleLogin } from '@react-oauth/google'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import { Button } from 'flowbite-react'; 5 | import AuthService from '../services/auth.service'; 6 | import { Spinner } from 'flowbite-react'; 7 | 8 | function GoogleLoginButton(props) { 9 | let navigate = useNavigate(); 10 | const [loading, setLoading] = useState(false); 11 | 12 | const handleLoginGoogle = useGoogleLogin( 13 | { 14 | flow: 'auth-code', 15 | ux_mode: 'popup', 16 | onSuccess: (codeResponse) => { 17 | AuthService.loginGoogle(codeResponse.code).then( 18 | response => { 19 | setLoading(false); 20 | navigate("/") 21 | }, 22 | error => { 23 | setLoading(false); 24 | } 25 | ) 26 | }, 27 | onNonOAuthError: () => { 28 | setLoading(false); 29 | } 30 | }); 31 | 32 | return ( 33 | <> 34 | {loading ? ( 35 | 38 | ): ( 39 | 46 | )} 47 | 48 | 49 | 50 | ) 51 | } 52 | 53 | export default GoogleLoginButton -------------------------------------------------------------------------------- /web/src/components/Library/BookItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Progress, Badge } from "flowbite-react"; 3 | import { Link } from 'react-router-dom'; 4 | import UpdateReadingStatusButton from '../UpdateReadingStatusButton'; 5 | import ActionsBookLibraryButton from '../ActionsBookLibraryButton'; 6 | import BookRating from './BookRating'; 7 | import Skeleton from 'react-loading-skeleton' 8 | import NotesIcon from '../NotesIcon'; 9 | import { Img } from 'react-image' 10 | 11 | function BookItem(props) { 12 | const [imageLoaded, setImageLoaded] = useState(false); 13 | console.log(props) 14 | return ( 15 |
16 | } 18 | unloader={} 19 | /> 20 |
21 | 22 |
{props.title}
23 | 24 |

by {props.author}

25 | 26 | {props.showReadingStatusBadge && 27 | {props.readingStatus} 28 | } 29 | 30 | {props.showProgress && 31 | <> 32 | 33 |
34 |
35 | 36 |
37 | 38 |
39 | 40 | } 41 |
42 | {props.showRating && 43 | 44 | } 45 | {props.showNotes && 46 | (props.notes > 0 && 47 | 48 | ) 49 | } 50 |
51 | {props.showOptions && 52 |
53 | 54 |
55 | } 56 |
57 |
58 | ) 59 | } 60 | 61 | BookItem.defaultProps = { 62 | showProgress: true, 63 | showOptions: true, 64 | showReadingStatusBadge: false, 65 | showRating: true, 66 | disableGiveRating: false, 67 | showNotes:true, 68 | overrideNotes: undefined, 69 | allowNoteEditing: true, 70 | } 71 | 72 | export default BookItem -------------------------------------------------------------------------------- /web/src/components/Library/BookRating.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Modal, Rating, Button, RangeSlider, TextInput, Tooltip } from 'flowbite-react' 3 | import useToast from '../../toast/useToast'; 4 | import BooksService from '../../services/books.service'; 5 | 6 | function BookRating(props) { 7 | const [openModal, setOpenModal] = useState(false); 8 | const [rangeValue, setRangeValue] = useState(props.rating ? props.rating : 0); 9 | const [ratingErrorText, setRatingErrorText] = useState(); 10 | const [saveButtonDisabled, setSaveButtonDisabled] = useState(false); 11 | 12 | const toast = useToast(4000); 13 | 14 | const handleRateBook = () => { 15 | BooksService.edit(props.id, {rating: rangeValue}).then( 16 | response => { 17 | toast("success", response.data.message); 18 | setOpenModal(false); 19 | props.onSuccess(); 20 | }, 21 | error => { 22 | const resMessage = 23 | (error.response && 24 | error.response.data && 25 | error.response.data.message) || 26 | error.message || 27 | error.toString(); 28 | setOpenModal(false); 29 | toast("error", resMessage); 30 | } 31 | ) 32 | } 33 | 34 | const handleOpenModal = () => { 35 | if(!props.disableGiveRating) { 36 | setOpenModal(true) 37 | } 38 | } 39 | 40 | useEffect(() => { 41 | if (rangeValue > 5) { 42 | setRatingErrorText("Rating cannot be more than 5"); 43 | setSaveButtonDisabled(true); 44 | } else if (rangeValue < 0) { 45 | setRatingErrorText("Rating cannot be less than 0."); 46 | setSaveButtonDisabled(true); 47 | } else { 48 | setRatingErrorText(); 49 | setSaveButtonDisabled(false); 50 | } 51 | }, [rangeValue]) 52 | 53 | const rating = () => { 54 | return ( 55 | handleOpenModal()} className={`${!props.disableGiveRating ? "hover:bg-gray-100 hover:cursor-pointer":""} w-fit`}> 56 | = 1 ? true : false} /> 57 | = 2 ? true : false} /> 58 | = 3 ? true : false} /> 59 | = 4 ? true : false} /> 60 | = 5 ? true : false} /> 61 |

{props.rating}

62 |
63 | ) 64 | } 65 | 66 | return ( 67 | <> 68 | {props.disableGiveRating ? ( 69 | rating() 70 | ): ( 71 | 72 | {rating()} 73 | 74 | )} 75 | 76 | setOpenModal(false)}> 77 | Rate book 78 | 79 |

How many stars would you like to give {props.title}?

80 |
81 |
82 | setRangeValue(e.target.value)}/> 83 |
84 |
85 | setRangeValue(e.target.value)} color={ratingErrorText ? 'failure' : 'gray'} /> 86 | 87 |
88 | 89 |
90 | 91 | {ratingErrorText} 92 | 93 |
94 | 95 | 96 | 99 | 100 |
101 | 102 | ) 103 | } 104 | 105 | BookRating.defaultProps = { 106 | size: "sm", 107 | disableGiveRating: false, 108 | } 109 | 110 | export default BookRating -------------------------------------------------------------------------------- /web/src/components/Library/LibraryPane.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useReducer } from 'react' 2 | import BookItem from './BookItem' 3 | import { Tabs } from "flowbite-react"; 4 | import BooksService from '../../services/books.service'; 5 | import PaneTabView from './PaneTabView'; 6 | import reducer, { initialState, actionTypes } from '../../useLibraryReducer'; 7 | import { RiBook2Line } from "react-icons/ri"; 8 | import { RiBookOpenLine } from "react-icons/ri"; 9 | import { RiBookmarkLine } from "react-icons/ri"; 10 | 11 | function LibraryPane() { 12 | const [activeTab, setActiveTab] = useState(0); 13 | const [state, dispatch] = useReducer(reducer, initialState); 14 | 15 | 16 | useEffect(() => { 17 | 18 | getBooks(translateTabsToStatus()); 19 | 20 | }, [activeTab]) 21 | 22 | const translateTabsToStatus = () => { 23 | let status = "Currently reading"; 24 | if (activeTab == 0) { 25 | status = "Currently reading" 26 | } else if (activeTab == 1) { 27 | status = "To be read" 28 | }else if (activeTab == 2) { 29 | status = "Read" 30 | } 31 | return status; 32 | } 33 | 34 | const getBooks = (status) => { 35 | BooksService.get(status).then( 36 | response => { 37 | dispatch({type: actionTypes.BOOKS, books: response.data}) 38 | } 39 | ) 40 | } 41 | 42 | return ( 43 | <> 44 |
45 |

My Library

46 |
47 | setActiveTab(tab)} variant="underline" className="pt-1"> 48 | 49 | 50 | {state.books?.map((item) => { 51 | return ( 52 |
53 | getBooks(translateTabsToStatus())}/> 54 |
55 | ) 56 | })} 57 |
58 |
59 | 60 | 61 | {state.books?.map((item) => { 62 | return ( 63 |
64 | getBooks(translateTabsToStatus())}/> 65 |
66 | ) 67 | })} 68 |
69 |
70 | 71 | 72 | {state.books?.map((item) => { 73 | return ( 74 |
75 | getBooks(translateTabsToStatus())} /> 76 |
77 | ) 78 | })} 79 |
80 |
81 |
82 | {state.books?.length <= 0 && 83 |
84 | 85 |
86 |

No books found

87 |

There does not seem to be any books in this list. Use the search to find a book and add it to your list.

88 |
89 |
90 | } 91 | 92 | ) 93 | } 94 | 95 | export default LibraryPane -------------------------------------------------------------------------------- /web/src/components/Library/PaneTabView.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function PaneTabView(props) { 4 | return ( 5 |
6 | {props.children} 7 |
8 | ) 9 | } 10 | 11 | export default PaneTabView -------------------------------------------------------------------------------- /web/src/components/MastodonTab.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Popover, Button, Checkbox, Label, TextInput } from "flowbite-react"; 3 | import { RiQuestionLine } from "react-icons/ri"; 4 | import UserSettingsServices from '../services/userSettings.service'; 5 | import useToast from '../toast/useToast'; 6 | 7 | function MastodonTab() { 8 | const [userSettings, setUserSettings] = useState(); 9 | const [eventSharing, setEventSharing] = useState(); 10 | const [mastodonInstance, setMastodonInstance] = useState(); 11 | const [mastodonAccess, setMastodonAccess] = useState(); 12 | const [disableSaveButton, setDisableSaveButton] = useState(true); 13 | const toast = useToast(4000); 14 | 15 | const handleSave = () => { 16 | UserSettingsServices.edit({"send_book_events": eventSharing, "mastodon_url": mastodonInstance, "mastodon_access_token": mastodonAccess}).then( 17 | response => { 18 | toast("success", response.data.message) 19 | setDisableSaveButton(true) 20 | getUserSettings(); 21 | }, 22 | error => { 23 | if (error.response.status != 404) { 24 | const resMessage = 25 | (error.response && 26 | error.response.data && 27 | error.response.data.message) || 28 | error.message || 29 | error.toString(); 30 | console.log(error) 31 | toast("error", resMessage); 32 | } 33 | } 34 | ) 35 | }; 36 | 37 | const getUserSettings = () => { 38 | UserSettingsServices.get().then( 39 | response => { 40 | setUserSettings(response.data); 41 | setEventSharing(response.data.send_book_events); 42 | setMastodonInstance(response.data.mastodon_url) 43 | setMastodonAccess(response.data.mastodon_access_token) 44 | }, 45 | error => { 46 | if (error.response.status != 404) { 47 | const resMessage = 48 | (error.response && 49 | error.response.data && 50 | error.response.data.message) || 51 | error.message || 52 | error.toString(); 53 | console.log(error) 54 | toast("error", resMessage); 55 | } 56 | } 57 | ) 58 | }; 59 | 60 | useEffect(() => { 61 | getUserSettings(); 62 | }, []) 63 | 64 | const displayPopoverContent = ( 65 |
66 |
67 |

Help

68 |
69 |
70 |

See guide for how to connect your Mastodon account to BookLogr.

71 |
72 |
73 | ) 74 | 75 | return ( 76 |
77 |
78 | 79 |
80 | 81 |
82 |
83 |

Share events

84 |
85 |
86 |
87 | (setEventSharing(!eventSharing), setDisableSaveButton(!e.target.value))} /> 88 | 89 |
90 |
91 |
92 | 93 | This will enable BookLogr to listen for events such as when finishing a book and share it to social media. 94 | 95 |
96 |
97 |
98 |
99 | 100 |
101 | 102 |
103 |
104 |
105 |

Mastodon account

106 |
107 |
108 | 109 | 110 | 111 |
112 |
113 |
114 |
115 |
118 | 119 |
120 |
123 |
124 |
125 |
126 | ) 127 | } 128 | 129 | export default MastodonTab -------------------------------------------------------------------------------- /web/src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navbar } from 'flowbite-react' 3 | import { Link, useLocation } from 'react-router-dom'; 4 | 5 | const customThemeNav = { 6 | root: { 7 | base: "bg-[#FDFCF7] px-2 py-2.5 dark:border-gray-700 dark:bg-gray-800 sm:px-4", 8 | }, 9 | link: { 10 | active: { 11 | on: "bg-cyan-700 underline text-white dark:text-white md:bg-transparent md:text-cyan-700" 12 | } 13 | } 14 | }; 15 | 16 | function NavigationMenu() { 17 | let location = useLocation(); 18 | 19 | return ( 20 | <> 21 |
22 | 23 | 24 | Logo 25 | BookLogr 26 | 27 | 28 | 29 | {import.meta.env.VITE_DISABLE_HOMEPAGE === "false" && 30 | 31 | Home 32 | 33 | } 34 | 35 | Login 36 | 37 | 38 | Register 39 | 40 | 41 | 42 |
43 | 44 | ) 45 | } 46 | 47 | export default NavigationMenu -------------------------------------------------------------------------------- /web/src/components/NotesIcon.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState} from 'react' 2 | import { RiStickyNoteLine } from "react-icons/ri"; 3 | import NotesView from './NotesView'; 4 | import { Tooltip } from 'flowbite-react'; 5 | 6 | function NotesIcon(props) { 7 | const [openNotesModal, setOpenNotesModal] = useState(); 8 | 9 | return ( 10 | <> 11 | 12 |
setOpenNotesModal(true)} className="flex flex-row gap-2 items-center hover:bg-gray-100 hover:cursor-pointer"> 13 | 14 |

{props.notes}

15 |
16 |
17 | 18 | 19 | ) 20 | } 21 | 22 | export default NotesIcon -------------------------------------------------------------------------------- /web/src/components/OpenLibraryButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Spinner, Button, TextInput } from "flowbite-react"; 3 | import { Link } from "react-router-dom"; 4 | import { HiOutlineBuildingLibrary } from "react-icons/hi2"; 5 | 6 | function OpenLibraryButton(props) { 7 | return ( 8 | 12 | ) 13 | } 14 | 15 | export default OpenLibraryButton -------------------------------------------------------------------------------- /web/src/components/SearchBar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo } from "react"; 2 | import axios from "axios"; 3 | import { debounce } from 'lodash'; 4 | import { TextInput } from "flowbite-react"; 5 | import { Link } from "react-router-dom"; 6 | import Skeleton from 'react-loading-skeleton' 7 | import 'react-loading-skeleton/dist/skeleton.css' 8 | import { RiSearch2Line } from "react-icons/ri"; 9 | import ESCIcon from "./ESCIcon"; 10 | import { Img } from 'react-image' 11 | import { RiErrorWarningLine } from "react-icons/ri"; 12 | 13 | function SearchBar(props) { 14 | const [searchTerm, setSearchTerm] = useState(''); 15 | const [suggestions, setSuggestions] = useState([{id: 0, name: ""}]); 16 | const [noSuggestionsFound, setNoSuggestionsFound] = useState(false); 17 | const [loading, setLoading] = useState(false); 18 | const [showList, setShowList] = useState(false); 19 | const loadingPlaceholder = [0,1,2,3,4,5] 20 | const [onError, setOnError] = useState(false); 21 | const [errorMessage, setErrorMessage] = useState(); 22 | 23 | const fetchSuggestions = (searchTerm) => { 24 | if (searchTerm) { 25 | axios.get(`https://openlibrary.org/search.json?q=${encodeURIComponent(searchTerm)}&limit=10&offset=0&fields=title,isbn`).then( 26 | response => { 27 | let newArray = [] 28 | for (let i = 0; i < response.data.docs.length; i++) { 29 | if (response.data.docs[i].isbn) { 30 | newArray.push({id: i, name: response.data.docs[i].title, isbn: response.data.docs[i].isbn[0]}) 31 | } 32 | } 33 | setSuggestions(newArray); // Assuming the API returns an array of suggestions*/ 34 | setLoading(false); 35 | setShowList(true); 36 | setNoSuggestionsFound(false) 37 | 38 | setOnError(false); 39 | setErrorMessage(); 40 | 41 | if (response.data.num_found == 0) { 42 | setSuggestions() 43 | setNoSuggestionsFound(true) 44 | } 45 | 46 | }, 47 | error => { 48 | setLoading(false); 49 | setOnError(true); 50 | setErrorMessage(error.code) 51 | console.error('Error fetching suggestions:', error.code); 52 | } 53 | ) 54 | } 55 | }; 56 | 57 | useEffect(() => { 58 | if (searchTerm.trim() === '') { 59 | setSuggestions([]); 60 | setLoading(false); 61 | setShowList(false); 62 | } 63 | }, [searchTerm]); 64 | 65 | const changeHandler = (e) => { 66 | if (e.target.value) { 67 | setLoading(true); 68 | setShowList(true); 69 | setOnError(false); 70 | setErrorMessage(); 71 | fetchSuggestions(e.target.value) 72 | } 73 | } 74 | 75 | const debouncedChangeHandler = useMemo( 76 | () => debounce(changeHandler, 500) 77 | ,[]); 78 | 79 | return ( 80 |
81 | (debouncedChangeHandler(e), setSearchTerm(e.target.value))} value={searchTerm} /> 82 |
83 | {loading ? ( 84 | loadingPlaceholder.map(function() { 85 | return ( 86 |
87 |
88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 |
96 |
97 | ) 98 | }) 99 | ): ( 100 | suggestions?.map(function(data) { 101 | return ( 102 |
103 |
104 |
105 | } 107 | unloader={} 108 | /> 109 |
110 |
111 | showList(false)}> 112 |

{data.name}

113 | {data.isbn} 114 | 115 |
116 |
117 |
118 |
119 | ) 120 | }) 121 | )} 122 | {noSuggestionsFound && 123 |
124 | 125 |
126 |

No results found

127 |

Try searching for a different title or isbn.

128 |
129 |
130 | } 131 | {onError && 132 |
133 | 134 |
135 |

Something went wrong

136 |

Try again later.

{errorMessage}. Learn more

137 | 138 |
139 |
140 | } 141 |
142 | {props.showAttribution && 143 |

Search powered by OpenLibrary

144 | } 145 |
146 | ) 147 | } 148 | 149 | SearchBar.defaultProps = { 150 | absolute: true, 151 | hideESCIcon: true, 152 | showAttribution: true 153 | } 154 | export default SearchBar -------------------------------------------------------------------------------- /web/src/components/SidebarNav.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import SearchBar from './SearchBar' 3 | import { Sidebar, Modal } from 'flowbite-react' 4 | import { Link, useLocation } from 'react-router-dom'; 5 | import AuthService from '../services/auth.service'; 6 | import { RiBook2Line } from "react-icons/ri"; 7 | import { RiUser3Line } from "react-icons/ri"; 8 | import { RiLogoutBoxLine } from "react-icons/ri"; 9 | import { RiSideBarLine } from "react-icons/ri"; 10 | import { RiSideBarFill } from "react-icons/ri"; 11 | import { RiSearch2Line } from "react-icons/ri"; 12 | import { RiLoginBoxLine } from "react-icons/ri"; 13 | import { RiSettings4Line } from "react-icons/ri"; 14 | 15 | const customTheme = { 16 | root: { 17 | inner: "h-full overflow-y-auto overflow-x-hidden rounded bg-[#FDFCF7] py-4 px-3 dark:bg-gray-800 border-r", 18 | } 19 | }; 20 | 21 | function SidebarNav() { 22 | const [sidebarState, setSidebarState] = useState(true); 23 | const [openSearchModal, setOpenSearchModal] = useState(false); 24 | let location = useLocation(); 25 | 26 | return ( 27 | <> 28 | 29 | 30 | BookLogr 31 | 32 | 33 | 34 | {sidebarState ? ( 35 | setOpenSearchModal(true)}>Search 36 | ) :( 37 | 38 | )} 39 | 40 | 41 | My Library 42 | Profile 43 | 44 | Settings 45 | 46 | {AuthService.getCurrentUser() ? ( 47 | (AuthService.logout(), navigate("/"))} icon={RiLogoutBoxLine}>Logout 48 | ):( 49 | 50 | Login 51 | 52 | )} 53 | 54 | 55 | setSidebarState(!sidebarState)}> 56 | {sidebarState ? ( 57 | Expand 58 | ): ( 59 | Collapse 60 | )} 61 | 62 | 63 | 64 | 65 | 66 | {/* Mobile bottom navigation bar */} 67 |
68 |
69 | 70 | 74 | 75 | 76 | 80 | 81 | 82 | 86 | 87 | 88 | 92 | 93 |
94 |
95 | 96 | {/* Modal for search */} 97 | setOpenSearchModal(false)} position={"top-center"} size="md"> 98 | 99 | 100 | 101 | 102 | 103 | ) 104 | } 105 | 106 | export default SidebarNav -------------------------------------------------------------------------------- /web/src/components/UpdateReadingStatusButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { Button, TextInput, Modal } from 'flowbite-react' 3 | import { RiBookOpenLine } from "react-icons/ri"; 4 | import BooksService from '../services/books.service'; 5 | import useToast from '../toast/useToast'; 6 | import Confetti from 'react-confetti' 7 | import { RiMastodonFill } from "react-icons/ri"; 8 | import { Link } from 'react-router-dom'; 9 | import BookRating from './Library/BookRating'; 10 | 11 | function UpdateReadingStatusButton(props) { 12 | const [openModal, setOpenModal] = useState(false); 13 | const [openFinishModal, setOpenFinishModal] = useState(false); 14 | 15 | const [updatedProgress, setUpdatedProgress] = useState(); 16 | const [progressErrorText, setPasswordErrorText] = useState(); 17 | const [updateButtonDisabled, setUpdateButtonDisabled] = useState(false); 18 | 19 | const toast = useToast(4000); 20 | 21 | const updateProgress = () => { 22 | BooksService.edit(props.id, {current_page: updatedProgress}).then( 23 | response => { 24 | toast("success", response.data.message); 25 | setOpenModal(false); 26 | props.onSucess() 27 | } 28 | ) 29 | } 30 | 31 | useEffect(() => { 32 | if (updatedProgress > props.totalPages) { 33 | setPasswordErrorText("Current page cannot be greater than total pages."); 34 | setUpdateButtonDisabled(true); 35 | } else if (updatedProgress < 0) { 36 | setPasswordErrorText("Current page cannot be less than 0."); 37 | setUpdateButtonDisabled(true); 38 | } else { 39 | setPasswordErrorText(); 40 | setUpdateButtonDisabled(false); 41 | } 42 | }, [updatedProgress]) 43 | 44 | const setFinished = () => { 45 | BooksService.edit(props.id, {current_page: props.totalPages, status: "Read"}).then( 46 | response => { 47 | toast("success", response.data.message); 48 | setOpenModal(false); 49 | setOpenFinishModal(true); 50 | } 51 | ) 52 | } 53 | 54 | return ( 55 | <> 56 | 57 | setOpenModal(false)}> 58 | Update reading progress 59 | 60 |
61 |

{} {props.title}

62 |
63 |

I am on page

64 | setUpdatedProgress(e.target.value)} color={progressErrorText ? 'failure' : 'gray'}/> 65 |

out of {props.totalPages}

66 | 67 |
68 | 69 | {progressErrorText} 70 | 71 |
72 |
73 | 74 | 75 | 76 | 77 | 78 |
79 | 80 | (setOpenFinishModal(false), props.onSucess())} popup> 81 | 82 | 83 | 84 |
85 | 86 |
87 |

Congratulations!

88 |

On finishing reading {props.title}

89 | 90 |
91 |
92 |

Rate this book

93 | 94 |
95 |
96 |
97 | 98 | 102 | 103 |
104 | 105 | ) 106 | } 107 | 108 | export default UpdateReadingStatusButton -------------------------------------------------------------------------------- /web/src/components/WelcomeModal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useMemo } from 'react' 2 | import useToast from '../toast/useToast'; 3 | import ProfileService from '../services/profile.service'; 4 | import { Button, TextInput, Label, Modal, Popover, Select } from "flowbite-react"; 5 | import { RiQuestionLine } from "react-icons/ri"; 6 | 7 | function WelcomeModal() { 8 | 9 | const [showWelcomeScreen, setShowWelcomeScreen] = useState(); 10 | const [createDisplayName, setCreateDisplayName] = useState(); 11 | const [profileVisibility, setProfileVisibility] = useState("hidden"); 12 | const [contentIndex, setContentIndex] = useState(0); 13 | 14 | const toast = useToast(4000); 15 | 16 | const displayNamePopoverContent = ( 17 |
18 |
19 |

Help

20 |
21 |
22 |

A display name will be used on your profile page that lists all your books. A profile page can be private or public. Display name and visibility of the profile page can be changed at any time.

23 |

Your display name will also be used for a link to your profile page if you have set it to public.

24 |
25 |
26 | ) 27 | 28 | useEffect(() => { 29 | if (localStorage.getItem("show_welcome_screen") != "false") { 30 | getProfileData() 31 | } 32 | }, []) 33 | 34 | 35 | /* Note: this is checks if a profile exists or not, at the moment this indicates if a welcome screen should be shown. 36 | * if the user already has gone through the setup then a localstorage item will be set to indicate this instead. 37 | */ 38 | const getProfileData = () => { 39 | ProfileService.get().then( 40 | response => { 41 | if (response.status == 404) { 42 | setShowWelcomeScreen(true); 43 | } else { 44 | console.log(response) 45 | setShowWelcomeScreen(false); 46 | localStorage.setItem("show_welcome_screen", false); 47 | } 48 | }, 49 | error => { 50 | if (error.response) { 51 | if (error.response.status == 404) { 52 | setShowWelcomeScreen(true); 53 | } 54 | } 55 | } 56 | ) 57 | } 58 | 59 | const handleCreateProfile = (e) => { 60 | e.preventDefault(); 61 | ProfileService.create({"display_name": createDisplayName, "visibility": profileVisibility}).then( 62 | response => { 63 | toast("success", response.data.message); 64 | setContentIndex(1); 65 | localStorage.setItem("show_welcome_screen", false); 66 | }, 67 | error => { 68 | const resMessage = 69 | (error.response && 70 | error.response.data && 71 | error.response.data.message) || 72 | error.message || 73 | error.toString(); 74 | toast("error", resMessage); 75 | } 76 | ) 77 | } 78 | 79 | return ( 80 | <> 81 | {showWelcomeScreen && 82 | 83 | 84 | {contentIndex == 0 && 85 |
86 |
87 |

Welcome! 🎉

88 |

We are so happy to have you here! To get started tracking all the books you are reading you first need to choose a display name.

89 |
90 |
91 |
92 |
97 | setCreateDisplayName(e.target.value)} /> 98 |
99 |
100 |
102 | 106 |
107 | 108 |
109 | } 110 | {contentIndex == 1 && 111 |
112 |

You are all set!

113 |

Why don't you start by adding some of your favorites books to your lists? It's super simple!

114 | 115 |
116 | } 117 |
118 |
119 | } 120 | 121 | ) 122 | } 123 | 124 | export default WelcomeModal -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | html { 7 | font-family: "Libre Baskerville", system-ui, sans-serif; 8 | font-weight: 400; 9 | background-color: #FDFCF7; 10 | } 11 | } -------------------------------------------------------------------------------- /web/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.jsx' 4 | import './index.css' 5 | import { BrowserRouter } from "react-router-dom"; 6 | import { ToastProvider } from './toast/Context.jsx'; 7 | import { GoogleOAuthProvider } from '@react-oauth/google'; 8 | import { HelmetProvider } from 'react-helmet-async'; 9 | 10 | ReactDOM.createRoot(document.getElementById('root')).render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | , 22 | ) 23 | -------------------------------------------------------------------------------- /web/src/pages/BookDetails.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { useParams } from "react-router-dom"; 3 | import OpenLibraryService from '../services/openlibrary.service'; 4 | import OpenLibraryButton from '../components/OpenLibraryButton'; 5 | import AddToReadingListButtton from '../components/AddToReadingListButton'; 6 | import Skeleton from 'react-loading-skeleton' 7 | import 'react-loading-skeleton/dist/skeleton.css' 8 | import useToast from '../toast/useToast'; 9 | import { Helmet } from 'react-helmet-async'; 10 | import { Img } from 'react-image' 11 | import AnimatedLayout from '../AnimatedLayout'; 12 | 13 | function BookDetails() { 14 | let { id } = useParams(); 15 | const [data, setData] = useState(); 16 | const [description, setDescription] = useState(); 17 | const [imageLoaded, setImageLoaded] = useState(false); 18 | const [loading, setLoading] = useState(true); 19 | const toast = useToast(4000); 20 | 21 | useEffect(() => { 22 | OpenLibraryService.get(id).then( 23 | response => { 24 | setData(response.data["docs"][0]); 25 | setLoading(false); 26 | 27 | OpenLibraryService.getWorks(response.data["docs"][0].key).then( 28 | response => { 29 | if (response.data.description) { 30 | if (response.data.description.value) { 31 | setDescription(response.data.description.value) 32 | } else { 33 | setDescription(response.data.description) 34 | } 35 | } else { 36 | setDescription("No description found") 37 | } 38 | } 39 | ) 40 | }, 41 | error => { 42 | const resMessage = 43 | (error.response && 44 | error.response.data && 45 | error.response.data.message) || 46 | error.message || 47 | error.toString(); 48 | toast("error", "OpenLibrary: " + resMessage); 49 | } 50 | ) 51 | }, []) 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 |
64 | } 66 | unloader={} 67 | /> 68 |
69 |
70 |
71 |

{data?.title || }

72 |

by {data?.author_name?.[0] || }

73 |

{description || }

74 |

75 | Pages 76 | {loading ? ( 77 | 78 | ): ( 79 | data?.number_of_pages_median || 0 80 | )} 81 |

82 |

ISBN {id}

83 |
84 |
85 |
86 |
87 | 88 | 89 |
90 |
91 |
92 |
93 |
94 | ) 95 | } 96 | 97 | export default BookDetails -------------------------------------------------------------------------------- /web/src/pages/Home.jsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'flowbite-react'; 2 | import React, { useEffect } from 'react' 3 | import { useNavigate, Link } from 'react-router-dom' 4 | import FeatureSection from '../components/FeatureSection'; 5 | import CTA from '../components/CTA'; 6 | import AuthService from '../services/auth.service'; 7 | import AnimatedLayout from '../AnimatedLayout'; 8 | 9 | function Home() { 10 | let navigate = useNavigate(); 11 | useEffect(() => { 12 | if(String(import.meta.env.VITE_DISABLE_HOMEPAGE).toLowerCase() === "true") { 13 | return navigate("/library") 14 | } 15 | if(AuthService.getCurrentUser()) { 16 | return navigate("/library") 17 | } 18 | }, []) 19 | 20 | 21 | return ( 22 | 23 |
24 |
25 |
26 |
27 |

Keep Track of Your Reading Journey

28 |

Track your reading progress and share your library with friends using BookLogr.

29 |
30 | 31 |
32 |
33 |
34 | mockup 35 |
36 |
37 |
38 | 39 | 40 |
41 |
42 | ) 43 | } 44 | 45 | export default Home -------------------------------------------------------------------------------- /web/src/pages/Library.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LibraryPane from '../components/Library/LibraryPane' 3 | import WelcomeModal from '../components/WelcomeModal' 4 | import AnimatedLayout from '../AnimatedLayout' 5 | 6 | function Library() { 7 | return ( 8 | 9 |
10 | 11 | 12 |
13 |
14 | ) 15 | } 16 | 17 | export default Library -------------------------------------------------------------------------------- /web/src/pages/Login.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Link, useNavigate } from 'react-router-dom'; 3 | import AuthService from '../services/auth.service'; 4 | import { Button, Label, TextInput, Card } from 'flowbite-react'; 5 | import GoogleLoginButton from '../components/GoogleLoginButton'; 6 | import { RiMailLine } from "react-icons/ri"; 7 | import { RiLockPasswordLine } from "react-icons/ri"; 8 | import useToast from '../toast/useToast'; 9 | import AnimatedLayout from '../AnimatedLayout'; 10 | 11 | function Login() { 12 | const [username, setUsername] = useState(""); 13 | const [password, setPassword] = useState(""); 14 | let navigate = useNavigate(); 15 | const toast = useToast(8000); 16 | 17 | const handleLogin = (e) => { 18 | e.preventDefault(); 19 | AuthService.login(username, password).then( 20 | response => { 21 | toast("success", "Login successful") 22 | navigate("/") 23 | }, 24 | error => { 25 | const resMessage = 26 | (error.response && 27 | error.response.data && 28 | error.response.data.message) || 29 | error.message || 30 | error.toString(); 31 | toast("error", resMessage); 32 | } 33 | ) 34 | } 35 | 36 | return ( 37 | 38 |
39 |
40 |

Login to your account

41 |
42 | {String(import.meta.env.VITE_DEMO_MODE).toLowerCase() === "true" ? ( 43 |
44 |

This is a demo of BookLogr

45 |

Some features are disabled.

46 |
47 |

To login use,

48 |
    Email: demo@booklogr.app
49 |
    Password: demo
50 |
51 | ):( 52 | 53 | )} 54 | 55 | 56 |
57 |
58 |
59 |
61 | setUsername(e.target.value)}/> 62 |
63 |
64 |
65 |
67 | setPassword(e.target.value)} /> 68 |
69 | 70 | 71 |
72 |
73 |
74 |
75 | ) 76 | } 77 | 78 | export default Login -------------------------------------------------------------------------------- /web/src/pages/Register.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { Link, useNavigate } from 'react-router-dom'; 3 | import AuthService from '../services/auth.service'; 4 | import { Button, Label, TextInput, Card } from 'flowbite-react'; 5 | import { RiMailLine } from "react-icons/ri"; 6 | import { RiLockPasswordLine } from "react-icons/ri"; 7 | import { RiUser3Line } from "react-icons/ri"; 8 | import useToast from '../toast/useToast'; 9 | import AnimatedLayout from '../AnimatedLayout'; 10 | 11 | function Register() { 12 | const [email, setEmail] = useState(""); 13 | const [name, setName] = useState(""); 14 | const [password, setPassword] = useState(""); 15 | const [passwordConf, setPasswordConf] = useState(""); 16 | const [passwordErrorText, setPasswordErrorText] = useState(); 17 | const [registerButtonDisabled, setRegisterButtonDisabled] = useState(true); 18 | 19 | let navigate = useNavigate(); 20 | const toast = useToast(8000); 21 | 22 | const handleRegistration = (e) => { 23 | e.preventDefault(); 24 | AuthService.register(email, name, password).then( 25 | response => { 26 | toast("success", response.data.message) 27 | navigate("/verify", {state: {"email": email}}) 28 | }, 29 | error => { 30 | const resMessage = 31 | (error.response && 32 | error.response.data && 33 | error.response.data.message) || 34 | error.message || 35 | error.toString(); 36 | toast("error", resMessage); 37 | } 38 | ) 39 | } 40 | 41 | useEffect(() => { 42 | if (passwordConf !== password) { 43 | setPasswordErrorText("Passwords do not match"); 44 | setRegisterButtonDisabled(true); 45 | } 46 | if (!password && !passwordConf) { 47 | setPasswordErrorText(""); 48 | } 49 | if (password == passwordConf) { 50 | setPasswordErrorText(""); 51 | } 52 | }, [password, passwordConf]) 53 | 54 | useEffect(() => { 55 | if (passwordConf === password) { 56 | if (passwordConf && password && email && name) { 57 | setRegisterButtonDisabled(false); 58 | setPasswordErrorText("") 59 | } 60 | } 61 | }, [password, passwordConf, email, name, passwordErrorText]) 62 | 63 | 64 | return ( 65 | 66 |
67 |
68 |

Register an account

69 |
70 | 71 |
72 |
73 |
74 |
76 | setEmail(e.target.value)}/> 77 |
78 |
79 |
80 |
82 | setName(e.target.value)}/> 83 |
84 |
85 |
86 |
88 | setPassword(e.target.value)} /> 89 |
90 |
91 |
92 |
94 | setPasswordConf(e.target.value)} color={passwordErrorText ? 'failure' : 'gray'} helperText={passwordErrorText} /> 95 |
96 | 97 |
98 |
99 |
100 |
101 | ) 102 | } 103 | 104 | export default Register -------------------------------------------------------------------------------- /web/src/pages/Settings.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Tabs } from "flowbite-react"; 3 | import { RiAccountCircleLine } from "react-icons/ri"; 4 | import { RiDatabase2Line } from "react-icons/ri"; 5 | import DataTab from '../components/Data/DataTab'; 6 | import AccountTab from '../components/AccountTab'; 7 | import AnimatedLayout from '../AnimatedLayout'; 8 | import MastodonTab from '../components/MastodonTab'; 9 | import { RiMastodonLine } from "react-icons/ri"; 10 | 11 | function Settings() { 12 | const [activeTab, setActiveTab] = useState(0); 13 | return ( 14 | 15 |
16 |
17 |

Settings

18 |
19 | setActiveTab(tab)} variant="underline" className="pt-1"> 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | ) 33 | } 34 | 35 | export default Settings -------------------------------------------------------------------------------- /web/src/pages/Verify.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Label, TextInput, Card } from 'flowbite-react' 2 | import React, { useState, useEffect } from 'react' 3 | import { useNavigate, useLocation} from 'react-router-dom'; 4 | import AuthService from "../services/auth.service"; 5 | import { useToast } from '../toast/useToast'; 6 | import AnimatedLayout from '../AnimatedLayout'; 7 | 8 | function Verify() { 9 | const [code, setCode] = useState(); 10 | const [email, setEmail] = useState(); 11 | 12 | let navigate = useNavigate(); 13 | let location = useLocation(); 14 | const toast = useToast(4000); 15 | 16 | useEffect(() => { 17 | setEmail(location.state.email); 18 | }, [location.state]) 19 | 20 | 21 | const handleVerify = (e) => { 22 | e.preventDefault(); 23 | AuthService.verify(email, code).then( 24 | response => { 25 | toast("success", response.data.message + ". Please login!") 26 | navigate("/login") 27 | }, 28 | error => { 29 | const resMessage = 30 | (error.response && 31 | error.response.data && 32 | error.response.data.message) || 33 | error.message || 34 | error.toString(); 35 | toast("error", resMessage); 36 | } 37 | ) 38 | } 39 | 40 | const maskEmail = (address) => { 41 | const regex = /(^.|@[^@](?=[^@]*$)|\.[^.]+$)|./g; 42 | return address.replace(regex, (x, y) => y || '*') 43 | }; 44 | 45 | return ( 46 | 47 |
48 |
49 | 50 |

Verify your account

51 | {email && 52 |

A verification code has been sent to {maskEmail(email)}

53 | } 54 |
55 | {!email && 56 |
57 |
58 |
60 | setEmail(e.target.value)} /> 61 |
62 | } 63 |
64 |
65 |
67 | setCode(e.target.value)} /> 68 |
69 | 70 |
71 |
72 |
73 |
74 |
75 | ) 76 | } 77 | 78 | export default Verify -------------------------------------------------------------------------------- /web/src/services/auth-header.jsx: -------------------------------------------------------------------------------- 1 | export default function authHeader() { 2 | const user = JSON.parse(localStorage.getItem('auth_user')); 3 | 4 | if (user && user.access_token) { 5 | return { Authorization: 'Bearer ' + user.access_token }; 6 | } else { 7 | return {}; 8 | } 9 | } -------------------------------------------------------------------------------- /web/src/services/auth.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import globalRouter from "../GlobalRouter"; 3 | 4 | const API_URL = import.meta.env.VITE_AUTH_API_URL; 5 | 6 | 7 | axios.interceptors.response.use((response) => { 8 | return response 9 | }, async (error) => { 10 | if (error.response.status === 401) { 11 | logout(); 12 | globalRouter.navigate("/login"); 13 | } 14 | return Promise.reject(error) 15 | }); 16 | 17 | const register = (email, name, password) => { 18 | return axios.post(API_URL + "/v1/register", { 19 | email, 20 | name, 21 | password, 22 | }); 23 | }; 24 | 25 | const login = (email, password) => { 26 | return axios 27 | .post(API_URL + "/v1/login", { 28 | email, 29 | password, 30 | }) 31 | .then((response) => { 32 | if (response.data.access_token) { 33 | localStorage.setItem("auth_user", JSON.stringify(response.data)); 34 | } 35 | 36 | return response.data; 37 | }); 38 | }; 39 | 40 | const loginGoogle = (code) => { 41 | return axios.post(API_URL + "/v1/authorize/google", {code}).then((response) => { 42 | if (response.data.access_token) { 43 | localStorage.setItem("auth_user", JSON.stringify(response.data)); 44 | } 45 | return response.data 46 | }) 47 | } 48 | 49 | const logout = () => { 50 | /*TODO: Send logout request to auth-server so the token get invalidated. */ 51 | localStorage.removeItem("auth_user"); 52 | }; 53 | 54 | const getCurrentUser = () => { 55 | return JSON.parse(localStorage.getItem("auth_user")); 56 | }; 57 | 58 | const verify = (email, code) => { 59 | return axios.post(API_URL + "/v1/verify", { 60 | email, 61 | code, 62 | }); 63 | }; 64 | 65 | export default { 66 | register, 67 | login, 68 | logout, 69 | getCurrentUser, 70 | verify, 71 | loginGoogle, 72 | }; -------------------------------------------------------------------------------- /web/src/services/books.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | 4 | const API_URL = import.meta.env.VITE_API_ENDPOINT; 5 | 6 | const add = (data) => { 7 | return axios.post(API_URL + "v1/books", data, { headers: authHeader() }) 8 | }; 9 | 10 | const get = (status) => { 11 | if (status) { 12 | return axios.get(API_URL + "v1/books?status=" + status, { headers: authHeader() }) 13 | 14 | } else { 15 | return axios.get(API_URL + "v1/books", { headers: authHeader() }) 16 | 17 | } 18 | } 19 | 20 | const edit = (id, data) => { 21 | return axios.patch(API_URL + "v1/books/" + id, data, { headers: authHeader() }); 22 | }; 23 | 24 | const remove = (id) => { 25 | return axios.delete(API_URL + "v1/books/" + id, { headers: authHeader() }); 26 | }; 27 | 28 | const notes = (id) => { 29 | return axios.get(API_URL + "v1/books/" + id + "/notes", { headers: authHeader() }) 30 | }; 31 | 32 | const addNote = (id, data) => { 33 | return axios.post(API_URL + "v1/books/" + id + "/notes", data, { headers: authHeader() }) 34 | }; 35 | 36 | export default { 37 | add, 38 | get, 39 | edit, 40 | remove, 41 | notes, 42 | addNote, 43 | }; -------------------------------------------------------------------------------- /web/src/services/files.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | 4 | const API_URL = import.meta.env.VITE_API_ENDPOINT; 5 | 6 | const get = (filename) => { 7 | return axios.get(API_URL + "v1/files/" + filename, { responseType: "blob", headers: authHeader() }); 8 | }; 9 | 10 | const getAll = () => { 11 | return axios.get(API_URL + "v1/files", { headers: authHeader() }); 12 | }; 13 | 14 | 15 | export default { 16 | get, 17 | getAll, 18 | }; -------------------------------------------------------------------------------- /web/src/services/notes.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | 4 | const API_URL = import.meta.env.VITE_API_ENDPOINT; 5 | 6 | const edit = (id, data) => { 7 | return axios.patch(API_URL + "v1/notes/" + id, data, { headers: authHeader() }); 8 | }; 9 | 10 | const remove = (id) => { 11 | return axios.delete(API_URL + "v1/notes/" + id, { headers: authHeader() }); 12 | }; 13 | 14 | export default { 15 | edit, 16 | remove, 17 | }; -------------------------------------------------------------------------------- /web/src/services/openlibrary.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const API_URL = "https://openlibrary.org"; 4 | 5 | const get = (id) => { 6 | return axios.get(API_URL + "/search.json?isbn=" + id + "&fields=key,title,author_name,number_of_pages_median,first_publish_year,cover_edition_key,isbn", { }) 7 | }; 8 | 9 | const getWorks = (id) => { 10 | return axios.get(API_URL + id + ".json", { }) 11 | } 12 | 13 | export default { 14 | get, 15 | getWorks, 16 | }; -------------------------------------------------------------------------------- /web/src/services/profile.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | 4 | const API_URL = import.meta.env.VITE_API_ENDPOINT; 5 | 6 | const create = (data) => { 7 | return axios.post(API_URL + "v1/profiles", data, { headers: authHeader() }) 8 | }; 9 | 10 | const get_by_display_name = (display_name) => { 11 | return axios.get(API_URL + "v1/profiles/" + display_name) 12 | } 13 | 14 | const get = () => { 15 | return axios.get(API_URL + "v1/profiles", { headers: authHeader() }); 16 | }; 17 | 18 | const edit = (data) => { 19 | return axios.patch(API_URL + "v1/profiles", data, { headers: authHeader() }); 20 | } 21 | 22 | export default { 23 | create, 24 | get, 25 | get_by_display_name, 26 | edit, 27 | }; -------------------------------------------------------------------------------- /web/src/services/tasks.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | 4 | const API_URL = import.meta.env.VITE_API_ENDPOINT; 5 | 6 | const create = (type, data) => { 7 | return axios.post(API_URL + "v1/tasks", {type, data}, { headers: authHeader() }); 8 | }; 9 | 10 | export default { 11 | create, 12 | }; -------------------------------------------------------------------------------- /web/src/services/userSettings.service.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import authHeader from "./auth-header"; 3 | 4 | const API_URL = import.meta.env.VITE_API_ENDPOINT; 5 | 6 | const get = () => { 7 | return axios.get(API_URL + "v1/settings", { headers: authHeader() }); 8 | }; 9 | 10 | const edit = (data) => { 11 | return axios.patch(API_URL + "v1/settings", data, { headers: authHeader() }); 12 | } 13 | 14 | export default { 15 | get, 16 | edit, 17 | }; -------------------------------------------------------------------------------- /web/src/toast/Container.jsx: -------------------------------------------------------------------------------- 1 | import Toast from "./Toast"; 2 | import { useToastStateContext } from "./Context"; 3 | 4 | export default function ToastContainer() { 5 | const { toasts } = useToastStateContext(); 6 | 7 | return ( 8 |
9 |
10 | {toasts && 11 | toasts.map((toast) => )} 12 |
13 |
14 | ); 15 | } -------------------------------------------------------------------------------- /web/src/toast/Context.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useReducer, useContext } from "react"; 2 | 3 | const ToastStateContext = createContext({ toasts: [] }); 4 | const ToastDispatchContext = createContext(null); 5 | 6 | function ToastReducer(state, action) { 7 | switch (action.type) { 8 | case "ADD_TOAST": { 9 | return { 10 | ...state, 11 | toasts: [...state.toasts, action.toast], 12 | }; 13 | } 14 | case "DELETE_TOAST": { 15 | const updatedToasts = state.toasts.filter((e) => e.id != action.id); 16 | return { 17 | ...state, 18 | toasts: updatedToasts, 19 | }; 20 | } 21 | default: { 22 | throw new Error("unhandled action"); 23 | } 24 | } 25 | } 26 | 27 | export function ToastProvider({ children }) { 28 | const [state, dispatch] = useReducer(ToastReducer, { 29 | toasts: [], 30 | }); 31 | 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | } 38 | 39 | export const useToastStateContext = () => useContext(ToastStateContext); 40 | export const useToastDispatchContext = () => useContext(ToastDispatchContext); -------------------------------------------------------------------------------- /web/src/toast/Toast.jsx: -------------------------------------------------------------------------------- 1 | import { Alert } from "flowbite-react"; 2 | import { useToastDispatchContext } from "./Context"; 3 | import { IoMdInformationCircleOutline } from "react-icons/io"; 4 | import { MdErrorOutline } from "react-icons/md"; 5 | 6 | export default function Toast({ type, message, id }) { 7 | const dispatch = useToastDispatchContext(); 8 | return ( 9 | <> 10 | {type == "success" && ( 11 | { 12 | dispatch({ type: "DELETE_TOAST", id }); 13 | }}> 14 | {message} 15 | 16 | )} 17 | {type == "error" && ( 18 | { 19 | dispatch({ type: "DELETE_TOAST", id }); 20 | }}> 21 | {message} 22 | 23 | )} 24 | 25 | ); 26 | } -------------------------------------------------------------------------------- /web/src/toast/useToast.jsx: -------------------------------------------------------------------------------- 1 | import { useToastDispatchContext } from "./Context"; 2 | 3 | export function useToast(delay) { 4 | const dispatch = useToastDispatchContext(); 5 | 6 | function toast(type, message) { 7 | const id = Math.random().toString(36).substr(2, 9); 8 | dispatch({ 9 | type: "ADD_TOAST", 10 | toast: { 11 | type, 12 | message, 13 | id, 14 | }, 15 | }); 16 | 17 | setTimeout(() => { 18 | dispatch({ type: "DELETE_TOAST", id }); 19 | }, delay); 20 | } 21 | 22 | return toast; 23 | } 24 | 25 | export default useToast; -------------------------------------------------------------------------------- /web/src/useLibraryReducer.jsx: -------------------------------------------------------------------------------- 1 | export const initialState = { 2 | books: null, 3 | }; 4 | 5 | export const actionTypes = { 6 | BOOKS: "BOOKS", 7 | }; 8 | 9 | const reducer = (state, action) => { 10 | switch (action.type) { 11 | case actionTypes.BOOKS: 12 | return { 13 | ...state, 14 | books: action.books, 15 | }; 16 | 17 | default: 18 | return state; 19 | } 20 | }; 21 | 22 | export default reducer; -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const defaultTheme = require('tailwindcss/defaultTheme'); 3 | const flowbite = require("flowbite-react/tailwind"); 4 | 5 | export default { 6 | content: [ 7 | "./index.html", 8 | "./src/**/*.{js,ts,jsx,tsx}", 9 | flowbite.content(), 10 | ], 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | libre: ['"Libre Baskerville"', ...defaultTheme.fontFamily.sans] 15 | }, 16 | backgroundImage: { 17 | 'wave-pattern': "url('/wave.svg')", 18 | 'wave-02-pattern': "url('/wave_02.svg')", 19 | } 20 | }, 21 | }, 22 | plugins: [flowbite.plugin(), require('flowbite-typography'),], 23 | } 24 | 25 | -------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /workers/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.4 2 | WORKDIR /app 3 | 4 | COPY README.md pyproject.toml manage.py ./ 5 | COPY src ./src 6 | 7 | RUN pip install . 8 | 9 | CMD ["python", "manage.py", "listen"] -------------------------------------------------------------------------------- /workers/README.md: -------------------------------------------------------------------------------- 1 | # Workers 2 | This folder contains all code for the import workers and other background tasks that integrate with BookLogr. 3 | 4 | See the wiki article on [Background tasks](https://github.com/Mozzo1000/booklogr/wiki/Background-tasks) for more information. 5 | 6 | # Management CLI 7 | The `manage.py` script have two functions, one being it starts the event listener and will select the most appropriate worker for the event. And two, gives the administrator options to clear the queue and process backlogs. 8 | 9 | ## Start listener 10 | Run `manage.py listen` to start the worker listener. 11 | 12 | *NOTE: currently the listener is single-threaded so when it catches an event it will block until the worker has completed it's task. Multithreading will be added in the future.* 13 | 14 | ## List queue 15 | Run `manage.py queue` to list all current tasks in queue. 16 | 17 | ## Clear queue 18 | Run `manage.py clear` to clear the queue of any tasks that have not started any processing. 19 | 20 | ## Run backlog tasks 21 | In the event of the listener not picking up on tasks in time you can manually run all tasks that are backlogged. 22 | 23 | Run `manage.py backlog` to start processing the backlog. 24 | 25 | 26 | ## Relevant environment variables 27 | MASTO_URL 28 | MASTO_ACCESS 29 | EXPORT_FOLDER 30 | DATABASE_URL -------------------------------------------------------------------------------- /workers/manage.py: -------------------------------------------------------------------------------- 1 | import psycopg2 2 | import time 3 | from psycopg2.extras import RealDictCursor 4 | import argparse 5 | from dotenv import load_dotenv 6 | import os 7 | 8 | from src.export_csv import CSVWorker 9 | from src.export_json import JSONWorker 10 | from src.export_html import HTMLWorker 11 | from src.post_mastodon import MastodonWorker 12 | 13 | def main(): 14 | load_dotenv() 15 | conn = psycopg2.connect(os.getenv("DATABASE_URL")) 16 | conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) 17 | cursor = conn.cursor(cursor_factory=RealDictCursor) 18 | 19 | parser = argparse.ArgumentParser("booklogr-workers", description="Manager for the booklogr workers") 20 | parser.add_argument("OPTION", choices=["listen", "queue", "clear", "backlog"]) 21 | args = parser.parse_args() 22 | 23 | 24 | if args.OPTION == "listen": 25 | print("Listening for events..") 26 | cursor.execute(f"LISTEN task_created;") 27 | while True: 28 | handle_notify(conn, cursor) 29 | time.sleep(1) 30 | 31 | if args.OPTION == "queue": 32 | list_queue(cursor) 33 | if args.OPTION == "clear": 34 | clear_queue(cursor) 35 | 36 | if args.OPTION == "backlog": 37 | process_backlog(cursor) 38 | 39 | def process_backlog(cursor): 40 | cursor.execute(f"SELECT * FROM tasks WHERE status='fresh' AND worker IS NULL") 41 | results = cursor.fetchall() 42 | print(f"IN QUEUE: {len(results)}") 43 | for i in results: 44 | process_task(i, cursor) 45 | 46 | def clear_queue(cursor): 47 | cursor.execute(f"DELETE FROM tasks WHERE status='fresh' AND worker IS NULL") 48 | print("Deleted all from queue") 49 | 50 | 51 | def list_queue(cursor): 52 | cursor.execute(f"SELECT * FROM tasks WHERE status='fresh'") 53 | results = cursor.fetchall() 54 | print(f"IN QUEUE: {len(results)}") 55 | for i in results: 56 | print(i) 57 | 58 | def handle_notify(conn, cursor): 59 | conn.poll() 60 | id = None 61 | for notify in conn.notifies: 62 | print(notify.payload) 63 | id = notify.payload 64 | 65 | conn.notifies.clear() 66 | if id: 67 | cursor.execute(f"SELECT * FROM tasks WHERE id={id}") 68 | fetched = cursor.fetchone() 69 | if fetched: 70 | print("Found task to process") 71 | process_task(fetched, cursor) 72 | 73 | def process_task(data, cursor): 74 | print(f"Processing task with type {data['type']}") 75 | if data["type"] == "csv_export": 76 | CSVWorker(cursor).pickup_task(data["id"], data) 77 | elif data["type"] == "json_export": 78 | JSONWorker(cursor).pickup_task(data["id"], data) 79 | elif data["type"] == "html_export": 80 | HTMLWorker(cursor).pickup_task(data["id"], data) 81 | elif data["type"] == "share_book_event": 82 | MastodonWorker(cursor).pickup_task(data["id"], data) 83 | 84 | if __name__ == '__main__': 85 | main() -------------------------------------------------------------------------------- /workers/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "workers" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Andreas Backström "] 6 | readme = "README.md" 7 | packages = [{include = "src"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.12" 11 | psycopg2 = "^2.9.9" 12 | python-dotenv = "^1.0.1" 13 | jinja2 = "^3.1.5" 14 | mastodon-py = "^1.8.1" 15 | 16 | 17 | [build-system] 18 | requires = ["poetry-core"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /workers/src/example_worker.py: -------------------------------------------------------------------------------- 1 | import psycopg2 2 | import time 3 | 4 | WORKER_ID ="worker_name:1e0cf15d-3beb-4cf2-8453-00ad18f84d37" 5 | 6 | conn = psycopg2.connect(host="localhost", dbname="booklogr", user="admin", password="password") 7 | conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) 8 | 9 | cursor = conn.cursor() 10 | cursor.execute(f"LISTEN task_created;") 11 | 12 | def handle_notify(): 13 | conn.poll() 14 | id = None 15 | for notify in conn.notifies: 16 | print(notify.payload) 17 | id = notify.payload 18 | 19 | 20 | conn.notifies.clear() 21 | if id: 22 | cursor.execute(f"SELECT * FROM tasks WHERE id={id}") 23 | fetched = cursor.fetchone() 24 | if fetched: 25 | pickup_task(id, fetched) 26 | 27 | def pickup_task(id, data): 28 | print(data) 29 | current_time = time.strftime('%Y-%m-%d %H:%M:%S') 30 | print(f"Current time : {current_time}") 31 | cursor.execute(f"UPDATE tasks SET status='started', worker='{WORKER_ID}', updated_on='{current_time}' WHERE id={id}") 32 | 33 | while True: 34 | handle_notify() 35 | time.sleep(1) -------------------------------------------------------------------------------- /workers/src/export_csv.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | from dotenv import load_dotenv 4 | import os 5 | import string 6 | import random 7 | 8 | load_dotenv() 9 | 10 | if not os.path.exists(os.getenv("EXPORT_FOLDER")): 11 | os.makedirs(os.getenv("EXPORT_FOLDER")) 12 | 13 | class CSVWorker: 14 | def __init__(self, cursor): 15 | self.WORKER_ID = "csv_export:7d278a04-1440-4aa4-a330-3aa48cc2f515" 16 | self.cursor = cursor 17 | 18 | def pickup_task(self, id, data): 19 | self.cursor.execute(f"UPDATE tasks SET status='started', worker='{self.WORKER_ID}', updated_on='{time.strftime('%Y-%m-%d %H:%M:%S')}' WHERE id={id}") 20 | 21 | self.create_csv(data["created_by"]) 22 | self.cursor.execute(f"UPDATE tasks SET status='success', updated_on='{time.strftime('%Y-%m-%d %H:%M:%S')}' WHERE id={id}") 23 | print(f"[{self.WORKER_ID}] - Task completed.") 24 | 25 | 26 | def create_csv(self, owner_id): 27 | self.cursor.execute(f"SELECT title, isbn, description, reading_status, current_page, total_pages, author, rating FROM books WHERE owner_id={owner_id}") 28 | rows = self.cursor.fetchall() 29 | 30 | random_string = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) 31 | filename = f"export_{datetime.now().strftime("%y%m%d")}_{random_string}.csv" 32 | 33 | with open(os.path.join(os.getenv("EXPORT_FOLDER"), filename), "w") as f: 34 | f.write("title,isbn,description,reading_status,current_page,total_pages,author,rating\n") 35 | for row in rows: 36 | f.write(",".join([str(cell) for cell in row]) + "\n") 37 | 38 | self.cursor.execute("INSERT INTO files (filename, owner_id) VALUES (%s, %s)", (filename, owner_id)) -------------------------------------------------------------------------------- /workers/src/export_html.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | from dotenv import load_dotenv 4 | import os 5 | import string 6 | import random 7 | import json 8 | from jinja2 import Environment, FileSystemLoader 9 | 10 | load_dotenv() 11 | 12 | if not os.path.exists(os.getenv("EXPORT_FOLDER")): 13 | os.makedirs(os.getenv("EXPORT_FOLDER")) 14 | 15 | class HTMLWorker: 16 | def __init__(self, cursor): 17 | self.WORKER_ID = "html_export:97305e39-86fb-4433-8220-4a756a33b4e7" 18 | self.cursor = cursor 19 | 20 | self.env = Environment(loader=FileSystemLoader('src/html_templates')) 21 | 22 | 23 | def pickup_task(self, id, data): 24 | self.cursor.execute(f"UPDATE tasks SET status='started', worker='{self.WORKER_ID}', updated_on='{time.strftime('%Y-%m-%d %H:%M:%S')}' WHERE id={id}") 25 | 26 | self.create_html(data["created_by"]) 27 | self.cursor.execute(f"UPDATE tasks SET status='success', updated_on='{time.strftime('%Y-%m-%d %H:%M:%S')}' WHERE id={id}") 28 | print(f"[{self.WORKER_ID}] - Task completed.") 29 | 30 | 31 | def create_html(self, owner_id): 32 | self.cursor.execute(f"SELECT title, isbn, description, reading_status, current_page, total_pages, author, rating FROM books WHERE owner_id={owner_id}") 33 | rows = self.cursor.fetchall() 34 | 35 | random_string = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) 36 | filename = f"export_{datetime.now().strftime("%y%m%d")}_{random_string}.html" 37 | 38 | template = self.env.get_template("all_books.html") 39 | output = template.render(data=rows) 40 | 41 | with open(os.path.join(os.getenv("EXPORT_FOLDER"), filename), "w") as f: 42 | f.write(output) 43 | 44 | self.cursor.execute("INSERT INTO files (filename, owner_id) VALUES (%s, %s)", (filename, owner_id)) -------------------------------------------------------------------------------- /workers/src/export_json.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | from dotenv import load_dotenv 4 | import os 5 | import string 6 | import random 7 | import json 8 | 9 | load_dotenv() 10 | 11 | if not os.path.exists(os.getenv("EXPORT_FOLDER")): 12 | os.makedirs(os.getenv("EXPORT_FOLDER")) 13 | 14 | class JSONWorker: 15 | def __init__(self, cursor): 16 | self.WORKER_ID = "json_export:740a2d9e-aa47-44cd-b381-a08608f17862" 17 | self.cursor = cursor 18 | 19 | def pickup_task(self, id, data): 20 | self.cursor.execute(f"UPDATE tasks SET status='started', worker='{self.WORKER_ID}', updated_on='{time.strftime('%Y-%m-%d %H:%M:%S')}' WHERE id={id}") 21 | 22 | self.create_json(data["created_by"]) 23 | self.cursor.execute(f"UPDATE tasks SET status='success', updated_on='{time.strftime('%Y-%m-%d %H:%M:%S')}' WHERE id={id}") 24 | print(f"[{self.WORKER_ID}] - Task completed.") 25 | 26 | 27 | def create_json(self, owner_id): 28 | self.cursor.execute(f"SELECT title, isbn, description, reading_status, current_page, total_pages, author, rating FROM books WHERE owner_id={owner_id}") 29 | rows = self.cursor.fetchall() 30 | 31 | random_string = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) 32 | filename = f"export_{datetime.now().strftime("%y%m%d")}_{random_string}.json" 33 | 34 | with open(os.path.join(os.getenv("EXPORT_FOLDER"), filename), "w") as f: 35 | f.write(json.dumps(rows)) 36 | 37 | self.cursor.execute("INSERT INTO files (filename, owner_id) VALUES (%s, %s)", (filename, owner_id)) -------------------------------------------------------------------------------- /workers/src/html_templates/all_books.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exported books 5 | 22 | 23 | 24 | 25 |

All books ({{ data|length }})

26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% for item in data %} 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% endfor %} 49 |
TitleAuthorDescriptionISBNReading statusTotal pagesCurrent pageRating
{{ item.title }}{{ item.author }}{{ item.description }}{{ item.isbn }}{{ item.reading_status }}{{ item.total_pages }}{{ item.current_page }}{{ item.rating }}
50 | 51 | -------------------------------------------------------------------------------- /workers/src/post_mastodon.py: -------------------------------------------------------------------------------- 1 | import time 2 | from dotenv import load_dotenv 3 | import os 4 | from mastodon import Mastodon 5 | import json 6 | 7 | load_dotenv() 8 | 9 | if not os.path.exists(os.getenv("EXPORT_FOLDER")): 10 | os.makedirs(os.getenv("EXPORT_FOLDER")) 11 | 12 | class MastodonWorker: 13 | def __init__(self, cursor): 14 | self.WORKER_ID = "mastodon_share:1ce16504-044e-4150-8ff3-9899925ce0ab" 15 | self.cursor = cursor 16 | 17 | def pickup_task(self, id, data): 18 | self.cursor.execute(f"UPDATE tasks SET status='started', worker='{self.WORKER_ID}', updated_on='{time.strftime('%Y-%m-%d %H:%M:%S')}' WHERE id={id}") 19 | 20 | self.share_book(data) 21 | self.cursor.execute(f"UPDATE tasks SET status='success', updated_on='{time.strftime('%Y-%m-%d %H:%M:%S')}' WHERE id={id}") 22 | print(f"[{self.WORKER_ID}] - Task completed.") 23 | 24 | 25 | def share_book(self, data): 26 | self.cursor.execute(f"SELECT mastodon_url, mastodon_access_token FROM user_settings WHERE owner_id={data["created_by"]}") 27 | settings = self.cursor.fetchone() 28 | data = json.loads(data["data"]) 29 | 30 | mastodon = Mastodon(access_token=settings["mastodon_access_token"], api_base_url=settings["mastodon_url"]) 31 | if data["reading_status"] == "Read": 32 | mastodon.status_post(f"I just finished reading {data["title"]} by {data["author"]} 📖 ") --------------------------------------------------------------------------------