├── .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 |
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 COVER NOT 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 | Save
11 |
12 |
13 |
14 |
15 |
16 | Upload picture
17 | Remove
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 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
Change password
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 | removeBook()}>
89 | {"Yes, I'm sure"}
90 |
91 | setRemovalConfModal(false)}>
92 | {"No, cancel"}
93 |
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 |
setOpenModal(true)}>Add to list
58 |
setOpenModal(false)}>
59 | Add book to list
60 |
61 |
62 |
Book title: {props.data?.title}
63 |
ISBN: {props.isbn}
64 |
65 |
66 |
67 |
setReadingStatus(e.target.value)}>
68 | To be read
69 | Currently reading
70 | Read
71 |
72 |
73 |
74 |
75 |
76 |
setCurrentPage(e.target.value)} />
77 |
78 |
79 |
80 |
81 | setTotalPages(e.target.value)} />
82 |
83 |
84 |
85 | handleSave()}>Save
86 | setOpenModal(false)}>
87 | Close
88 |
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 |
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 |
11 | Get started!
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 | downloadFile(item.filename)}>Download
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 |
45 |
46 |
setDataFormat(e.target.value)}>
47 | CSV
48 | JSON
49 | HTML
50 |
51 |
52 |
requestData()}>Request data
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 |
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 |
27 |
28 |
29 | {/* Third feature */}
30 |
31 |
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 |
36 |
37 |
38 | ): (
39 | (handleLoginGoogle(), setLoading(true))}>
40 |
41 |
42 |
43 |
44 |
45 | Sign in with Google
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 |
39 | >
40 | }
41 |
42 | {props.showRating &&
43 |
44 | }
45 | {props.showNotes &&
46 | (props.notes > 0 &&
47 |
48 | )
49 | }
50 |
51 | {props.showOptions &&
52 |
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 | handleRateBook()} disabled={saveButtonDisabled}>Save
96 | setOpenModal(false)}>
97 | Close
98 |
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 | Save
79 |
80 |
81 |
82 |
83 |
Share events
84 |
85 |
86 |
87 | (setEventSharing(!eventSharing), setDisableSaveButton(!e.target.value))} />
88 | Enable event sharing
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 |
112 |
113 |
114 |
115 |
116 | (setMastodonInstance(e.target.value), setDisableSaveButton(!e.target.value))}/>
117 |
118 |
119 |
120 |
121 | (setMastodonAccess(e.target.value), setDisableSaveButton(!e.target.value))}/>
122 |
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 |
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 |
9 |
10 | Open Library
11 |
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 |
71 |
72 | Library
73 |
74 |
75 |
76 | setOpenSearchModal(true)}>
77 |
78 | Search
79 |
80 |
81 |
82 |
83 |
84 | Profile
85 |
86 |
87 |
88 |
89 |
90 | Settings
91 |
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 | setOpenModal(true)}>Update progress
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 | updateProgress()} disabled={updateButtonDisabled}>Update
75 | setOpenModal(false)}> Cancel
76 | setFinished()}>Set as finished
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 |
99 |
100 | Share on Mastodon
101 |
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 |
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 |
setShowWelcomeScreen(false)}>Close
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 |
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 | Get started!
31 |
32 |
33 |
34 |
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 |
49 |
50 |
51 | ):(
52 |
53 | )}
54 |
55 |
56 |
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 |
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 |
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 | Title
29 | Author
30 | Description
31 | ISBN
32 | Reading status
33 | Total pages
34 | Current page
35 | Rating
36 |
37 | {% for item in data %}
38 |
39 | {{ item.title }}
40 | {{ item.author }}
41 | {{ item.description }}
42 | {{ item.isbn }}
43 | {{ item.reading_status }}
44 | {{ item.total_pages }}
45 | {{ item.current_page }}
46 | {{ item.rating }}
47 |
48 | {% endfor %}
49 |
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"]} 📖 ")
--------------------------------------------------------------------------------