├── docs
├── static
│ ├── .nojekyll
│ └── img
│ │ ├── social-card.png
│ │ ├── feature_section_01.png
│ │ ├── feature_section_02.png
│ │ └── feature_section_03.png
├── src
│ ├── pages
│ │ ├── markdown-page.md
│ │ ├── index.module.css
│ │ └── index.js
│ ├── components
│ │ └── HomepageFeatures
│ │ │ ├── styles.module.css
│ │ │ └── index.js
│ └── css
│ │ └── custom.css
├── .gitignore
├── docs
│ ├── 04 - Features
│ │ ├── Demo-mode.md
│ │ └── Share-to-Mastodon.md
│ ├── 05 - Administration
│ │ └── Backup-and-restore.md
│ ├── Getting started.md
│ ├── 03 - Configuration
│ │ ├── Email-verification.md
│ │ └── Google-sign-in.md
│ ├── 07 - API
│ │ ├── create-task.api.mdx
│ │ ├── get-list-of-files.api.mdx
│ │ ├── get-profile-of-the-logged-in-user.api.mdx
│ │ ├── delete-note.api.mdx
│ │ ├── get-tasks.api.mdx
│ │ ├── download-file.api.mdx
│ │ ├── booklogr-api.info.mdx
│ │ ├── get-profile-by-name.api.mdx
│ │ ├── edit-profile.api.mdx
│ │ ├── get-notes-from-book.api.mdx
│ │ ├── remove-book-from-list.api.mdx
│ │ ├── get-books-in-list.api.mdx
│ │ ├── check-if-book-by-isbn-is-already-in-a-list.api.mdx
│ │ ├── create-profile-for-logged-in-user.api.mdx
│ │ ├── edit-book.api.mdx
│ │ ├── edit-note.api.mdx
│ │ ├── add-note-to-book.api.mdx
│ │ └── add-book-to-list.api.mdx
│ └── 06 - Developer
│ │ └── Translation.md
├── README.md
├── sidebars.js
└── package.json
├── api
├── README.md
├── utils.py
├── auth
│ ├── user_route.py
│ ├── decorators.py
│ └── models.py
├── decorators.py
├── commands
│ ├── tasks.py
│ └── user.py
├── config.py
├── book_template_export.html
├── routes
│ └── settings.py
└── app.py
├── web
├── .flowbite-react
│ ├── .gitignore
│ └── config.json
├── .vscode
│ └── extensions.json
├── public
│ ├── social-card.png
│ ├── fallback-cover.svg
│ └── fallback-cover-light.svg
├── postcss.config.js
├── .env.production
├── .env.example
├── src
│ ├── services
│ │ ├── api.utils.jsx
│ │ ├── auth-header.jsx
│ │ ├── tasks.service.jsx
│ │ ├── github.service.jsx
│ │ ├── userSettings.service.jsx
│ │ ├── notes.service.jsx
│ │ ├── files.service.jsx
│ │ ├── openlibrary.service.jsx
│ │ ├── profile.service.jsx
│ │ ├── books.service.jsx
│ │ └── auth.service.jsx
│ ├── GlobalRouter.jsx
│ ├── pages
│ │ ├── Library.jsx
│ │ ├── Settings.jsx
│ │ └── Verify.jsx
│ ├── useLibraryReducer.jsx
│ ├── useInterval.jsx
│ ├── components
│ │ ├── Library
│ │ │ ├── PaneTabView.jsx
│ │ │ └── EditBookModal.jsx
│ │ ├── BookStatsCard.jsx
│ │ ├── OpenLibraryButton.jsx
│ │ ├── Data
│ │ │ ├── DataTab.jsx
│ │ │ ├── RequestData.jsx
│ │ │ └── FileImport.jsx
│ │ ├── NotesIcon.jsx
│ │ ├── LanguageSwitcher.jsx
│ │ ├── Footer.jsx
│ │ ├── Navbar.jsx
│ │ ├── RegionSwitcher.jsx
│ │ ├── ESCIcon.jsx
│ │ ├── RemoveBookModal.jsx
│ │ ├── AccountTab.jsx
│ │ ├── SortSelector.jsx
│ │ ├── AddNoteModal.jsx
│ │ ├── InterfaceTab.jsx
│ │ ├── AddQuoteModal.jsx
│ │ ├── UpdateReadingStatusView.jsx
│ │ ├── EditionSelector.jsx
│ │ ├── EditNoteModal.jsx
│ │ └── GoogleLoginButton.jsx
│ ├── index.css
│ ├── toast
│ │ ├── Container.jsx
│ │ ├── useToast.jsx
│ │ ├── Toast.jsx
│ │ └── Context.jsx
│ ├── AnimatedLayout.jsx
│ ├── main.jsx
│ ├── i18n.jsx
│ ├── DateFormat.jsx
│ └── App.jsx
├── nginx-custom.conf
├── .dockerignore
├── vite.config.js
├── .gitignore
├── inject-env.sh
├── README.md
├── Dockerfile
├── .eslintrc.cjs
├── index.html
└── package.json
├── migrations
├── README
├── script.py.mako
├── versions
│ ├── e60887c12d97_add_author_column.py
│ ├── ae48d4b4f9d8_add_owner_id_column.py
│ ├── 334c6f93b980_add_rating_column.py
│ ├── c9e06189519a_add_quote_page_column_to_notes_table.py
│ ├── a1a14989ad81_add_created_on_column_to_books_table.py
│ ├── 82989182da4a_add_files_table.py
│ ├── 5c9e5e91be8f_add_notes_table.py
│ ├── ce3cee57bdd9_initial_migration.py
│ ├── 9232f4adc495_add_tasks_table.py
│ ├── 89b6827408dd_add_user_settings_table.py
│ ├── 4f342e92ab1f_add_profiles_table.py
│ └── 8f8a46fec66b_add_auth_tables.py
├── alembic.ini
└── env.py
├── assets
├── library.png
└── mastodon-enable-share.png
├── .gitignore
├── .github
├── release.yml
└── workflows
│ ├── deploy-docs.yml
│ ├── build-docker-image.yml
│ └── build-docker-image-web.yml
├── Dockerfile
├── entrypoint.sh
├── .env.example
├── docker-compose.yml
├── pyproject.toml
├── docker-compose.postgres.yml
└── README.md
/docs/static/.nojekyll:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/api/README.md:
--------------------------------------------------------------------------------
1 | # minimal-reading-api
--------------------------------------------------------------------------------
/web/.flowbite-react/.gitignore:
--------------------------------------------------------------------------------
1 | class-list.json
2 | pid
--------------------------------------------------------------------------------
/migrations/README:
--------------------------------------------------------------------------------
1 | Single-database configuration for Flask.
2 |
--------------------------------------------------------------------------------
/assets/library.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/assets/library.png
--------------------------------------------------------------------------------
/web/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "bradlc.vscode-tailwindcss"
4 | ]
5 | }
--------------------------------------------------------------------------------
/web/public/social-card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/web/public/social-card.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | .env
3 | export_data
4 | auth_db_vol
5 | booklogr
6 | instance
7 | book.db
8 | .vscode
--------------------------------------------------------------------------------
/web/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | "@tailwindcss/postcss": {}
4 | },
5 | }
6 |
--------------------------------------------------------------------------------
/assets/mastodon-enable-share.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/assets/mastodon-enable-share.png
--------------------------------------------------------------------------------
/docs/static/img/social-card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/docs/static/img/social-card.png
--------------------------------------------------------------------------------
/web/.env.production:
--------------------------------------------------------------------------------
1 | VITE_API_ENDPOINT=BL_API_ENDPOINT
2 | VITE_GOOGLE_CLIENT_ID=BL_GOOGLE_ID
3 | VITE_DEMO_MODE=BL_DEMO_MODE
--------------------------------------------------------------------------------
/docs/static/img/feature_section_01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/docs/static/img/feature_section_01.png
--------------------------------------------------------------------------------
/docs/static/img/feature_section_02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/docs/static/img/feature_section_02.png
--------------------------------------------------------------------------------
/docs/static/img/feature_section_03.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mozzo1000/booklogr/HEAD/docs/static/img/feature_section_03.png
--------------------------------------------------------------------------------
/web/.env.example:
--------------------------------------------------------------------------------
1 | VITE_API_ENDPOINT="http://localhost:5000/"
2 | VITE_GOOGLE_CLIENT_ID=XXX.apps.googleusercontent.com
3 | VITE_DEMO_MODE=false
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | categories:
3 | - title: Added
4 | - title: Fixed
5 | - title: Changed
6 | - title: Removed
7 |
--------------------------------------------------------------------------------
/web/src/services/api.utils.jsx:
--------------------------------------------------------------------------------
1 | export const getAPIUrl = (endpoint) => {
2 | return new URL(endpoint, import.meta.env.VITE_API_ENDPOINT).toString();
3 | };
--------------------------------------------------------------------------------
/web/src/GlobalRouter.jsx:
--------------------------------------------------------------------------------
1 |
2 | import { Navigate } from "react-router-dom";
3 |
4 | const globalRouter = { Navigate };
5 |
6 | export default globalRouter;
7 |
8 |
--------------------------------------------------------------------------------
/docs/src/pages/markdown-page.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Markdown page example
3 | ---
4 |
5 | # Markdown page example
6 |
7 | You don't need React to write simple standalone pages.
8 |
--------------------------------------------------------------------------------
/web/.flowbite-react/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/flowbite-react/schema.json",
3 | "components": [],
4 | "dark": true,
5 | "prefix": "",
6 | "path": "src/components",
7 | "tsx": true,
8 | "rsc": true
9 | }
--------------------------------------------------------------------------------
/docs/src/components/HomepageFeatures/styles.module.css:
--------------------------------------------------------------------------------
1 | .features {
2 | display: flex;
3 | align-items: center;
4 | padding: 2rem 0;
5 | width: 100%;
6 | }
7 |
8 | .featureImg {
9 | height: 200px;
10 | width: 200px;
11 | }
12 |
--------------------------------------------------------------------------------
/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/.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/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 | }
--------------------------------------------------------------------------------
/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_APP api.app
10 | ENV FLASK_ENV production
11 | RUN chmod +x entrypoint.sh
12 |
13 | EXPOSE 5000
14 | ENTRYPOINT ["/app/entrypoint.sh"]
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | /node_modules
3 |
4 | # Production
5 | /build
6 |
7 | # Generated files
8 | .docusaurus
9 | .cache-loader
10 |
11 | # Misc
12 | .DS_Store
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 |
18 | npm-debug.log*
19 | yarn-debug.log*
20 | yarn-error.log*
21 |
--------------------------------------------------------------------------------
/web/src/services/tasks.service.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import authHeader from "./auth-header";
3 | import { getAPIUrl } from "./api.utils";
4 |
5 | const create = (type, data) => {
6 | return axios.post(getAPIUrl("v1/tasks"), {type, data}, { headers: authHeader() });
7 | };
8 |
9 | export default {
10 | create,
11 | };
--------------------------------------------------------------------------------
/web/vite.config.js:
--------------------------------------------------------------------------------
1 | import tailwindcss from "@tailwindcss/vite";
2 | import react from "@vitejs/plugin-react";
3 | import { defineConfig } from "vite";
4 | import flowbiteReact from "flowbite-react/plugin/vite";
5 |
6 | // https://vite.dev/config/
7 | export default defineConfig({
8 | plugins: [react(), tailwindcss(), flowbiteReact()],
9 | });
10 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo "Running database migration.."
4 | python -m flask db upgrade
5 |
6 | if [ -n "${AUTH_DEFAULT_USER+1}" ]; then
7 | echo "\$AUTH_DEFAULT_USER is set"
8 | python -m flask user create "$AUTH_DEFAULT_USER" "$AUTH_DEFAULT_USER" user --password "$AUTH_DEFAULT_PASSWORD"
9 | fi
10 |
11 | echo "Starting gunicorn.."
12 | exec gunicorn -w 4 -b :5000 api.app:app
--------------------------------------------------------------------------------
/api/utils.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | def validate_date_string(date_str):
4 | formats = [
5 | "%Y-%m-%d",
6 | "%Y-%m-%d %H:%M:%S",
7 | "%Y-%m-%d %H:%M:%S.%f"
8 | ]
9 |
10 | for fmt in formats:
11 | try:
12 | return datetime.strptime(date_str, fmt)
13 | except ValueError:
14 | continue
15 | return None
--------------------------------------------------------------------------------
/web/src/services/github.service.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | const API_URL = "https://api.github.com/";
4 |
5 | const getLatestRelease = () => {
6 | return axios.get(API_URL + "repos/mozzo1000/booklogr/releases/latest", { headers: {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}})
7 | }
8 |
9 |
10 | export default {
11 | getLatestRelease
12 | };
--------------------------------------------------------------------------------
/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/src/services/userSettings.service.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import authHeader from "./auth-header";
3 | import { getAPIUrl } from "./api.utils";
4 |
5 | const get = () => {
6 | return axios.get(getAPIUrl("v1/settings"), { headers: authHeader() });
7 | };
8 |
9 | const edit = (data) => {
10 | return axios.patch(getAPIUrl("v1/settings"), data, { headers: authHeader() });
11 | }
12 |
13 | export default {
14 | get,
15 | edit,
16 | };
--------------------------------------------------------------------------------
/web/src/services/notes.service.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import authHeader from "./auth-header";
3 | import { getAPIUrl } from "./api.utils";
4 |
5 | const edit = (id, data) => {
6 | return axios.patch(getAPIUrl(`v1/notes/${id}`), data, { headers: authHeader() });
7 | };
8 |
9 | const remove = (id) => {
10 | return axios.delete(getAPIUrl(`v1/notes/${id}`), { headers: authHeader() });
11 | };
12 |
13 | export default {
14 | edit,
15 | remove,
16 | };
--------------------------------------------------------------------------------
/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/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/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/src/useInterval.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | export function useInterval(callback, delay) {
4 | const callbackRef = useRef();
5 |
6 | useEffect(() => {
7 | callbackRef.current = callback;
8 | }, [callback]);
9 |
10 | useEffect(() => {
11 | function tick() {
12 | callbackRef.current();
13 | }
14 | if (delay !== null) {
15 | let id = setInterval(tick, delay);
16 | return () => clearInterval(id);
17 | }
18 | }, [callback, delay]);
19 | };
--------------------------------------------------------------------------------
/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/src/components/Library/PaneTabView.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | function PaneTabView({children, view, setView}) {
4 | return (
5 | <>
6 | {view === "gallery" &&
7 |
8 | {children}
9 |
10 | }
11 |
12 | {view === "list" &&
13 |
14 | {children}
15 |
16 | }
17 | >
18 | )
19 | }
20 |
21 | export default PaneTabView
--------------------------------------------------------------------------------
/web/src/index.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @plugin 'flowbite-react/plugin/tailwindcss';
3 | @source '../.flowbite-react/class-list.json';
4 | @plugin "flowbite-typography";
5 |
6 | /*@config '../tailwind.config.js';*/
7 |
8 | @custom-variant dark (&:where(.dark, .dark *));
9 |
10 | @layer base {
11 | html, body {
12 | font-family: "Libre Baskerville", system-ui, sans-serif;
13 | font-weight: 400;
14 | @apply bg-[#FDFCF7]
15 | }
16 |
17 | .dark html, .dark body {
18 | @apply bg-[#121212]
19 | }
20 |
21 | }
--------------------------------------------------------------------------------
/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/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/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/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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/services/files.service.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import authHeader from "./auth-header";
3 | import { getAPIUrl } from "./api.utils";
4 |
5 | const get = (filename) => {
6 | return axios.get(getAPIUrl(`v1/files/${filename}`), { responseType: "blob", headers: authHeader() });
7 | };
8 |
9 | const getAll = () => {
10 | return axios.get(getAPIUrl("v1/files"), { headers: authHeader() });
11 | };
12 |
13 | const upload = (formData) => {
14 | return axios.post(getAPIUrl("v1/files"), formData, { headers: Object.assign({}, authHeader(), {"Content-Type": "multipart/form-data"}) });
15 | };
16 |
17 |
18 | export default {
19 | get,
20 | getAll,
21 | upload,
22 | };
--------------------------------------------------------------------------------
/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 + "/isbn/" + id + ".json")
7 | };
8 |
9 | const getWorks = (id) => {
10 | return axios.get(API_URL + id + ".json", { })
11 | }
12 |
13 | const getEditions = (work_id, limit=10, offset=0) => {
14 | return axios.get(API_URL + work_id + "/editions.json?limit=" + limit + "&offset=" + offset)
15 | }
16 |
17 | const getAuthor = (author_id) => {
18 | return axios.get(API_URL + author_id + ".json")
19 | }
20 |
21 | export default {
22 | get,
23 | getWorks,
24 | getEditions,
25 | getAuthor,
26 | };
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL=postgresql://admin:password@booklogr-db/booklogr
2 | AUTH_SECRET_KEY=this-really-needs-to-be-changed
3 | AUTH_ALLOW_REGISTRATION=True
4 | AUTH_REQUIRE_VERIFICATION=False
5 | GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
6 | GOOGLE_CLIENT_SECRET=xxx
7 | EXPORT_FOLDER="export_data"
8 |
9 | # UNCOMMENT TO ADD A USER AT STARTUP, REQUIRED FOR DEMO MODE
10 | #AUTH_DEFAULT_USER=demo@booklogr.app
11 | #AUTH_DEFAULT_PASSWORD=demo
12 |
13 | # MAIL SETTINGS
14 | MAIL_SERVER=smtp.example.com
15 | MAIL_USERNAME=username
16 | MAIL_PASSWORD=password
17 | MAIL_SENDER=noreply@example.com
18 |
19 | # docker-compose
20 | POSTGRES_USER=admin
21 | POSTGRES_PASSWORD=password
22 | POSTGRES_DB=booklogr
--------------------------------------------------------------------------------
/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 './i18n';
9 |
10 | ReactDOM.createRoot(document.getElementById('root')).render(
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | ,
20 | )
21 |
--------------------------------------------------------------------------------
/web/src/i18n.jsx:
--------------------------------------------------------------------------------
1 | import i18n from "i18next";
2 | import { initReactI18next } from "react-i18next";
3 | import LanguageDetector from "i18next-browser-languagedetector";
4 | import en from "./locales/en/en.json";
5 | import sv from "./locales/sv/sv.json";
6 | import ar from "./locales/ar/ar.json";
7 | import cn from "./locales/zh-CN/zh-CN.json";
8 |
9 | const resources = {
10 | en: {
11 | translation: en,
12 | },
13 | sv: {
14 | translation: sv,
15 | },
16 | ar: {
17 | translation: ar,
18 | },
19 | cn: {
20 | translation: cn,
21 | },
22 | };
23 |
24 | i18n.use(LanguageDetector).use(initReactI18next).init({
25 | resources,
26 | fallbackLng: "en",
27 | debug: true,
28 | });
29 |
30 | export default i18n;
31 |
--------------------------------------------------------------------------------
/web/src/services/profile.service.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import authHeader from "./auth-header";
3 | import { getAPIUrl } from "./api.utils";
4 |
5 | const create = (data) => {
6 | return axios.post(getAPIUrl("v1/profiles"), data, { headers: authHeader() })
7 | };
8 |
9 | const get_by_display_name = (display_name) => {
10 | return axios.get(getAPIUrl(`v1/profiles/${display_name}`))
11 | }
12 |
13 | const get = () => {
14 | return axios.get(getAPIUrl("v1/profiles"), { headers: authHeader() });
15 | };
16 |
17 | const edit = (data) => {
18 | return axios.patch(getAPIUrl("v1/profiles"), data, { headers: authHeader() });
19 | }
20 |
21 | export default {
22 | create,
23 | get,
24 | get_by_display_name,
25 | edit,
26 | };
--------------------------------------------------------------------------------
/docs/src/pages/index.module.css:
--------------------------------------------------------------------------------
1 | /**
2 | * CSS files with the .module.css suffix will be treated as CSS modules
3 | * and scoped locally.
4 | */
5 |
6 | .heroBanner {
7 | padding: 4rem 0;
8 | text-align: center;
9 | position: relative;
10 | overflow: hidden;
11 | background-color: #fff;
12 | }
13 |
14 | [data-theme='dark'] {
15 | .heroBanner {
16 | padding: 4rem 0;
17 | text-align: center;
18 | position: relative;
19 | overflow: hidden;
20 | background-color: #20201D;
21 | }
22 |
23 | }
24 |
25 | @media screen and (max-width: 996px) {
26 | .heroBanner {
27 | padding: 2rem;
28 | }
29 | }
30 |
31 | .buttons {
32 | display: flex;
33 | align-items: center;
34 | justify-content: center;
35 | gap: 1rem;
36 | padding-top: 2rem;
37 | }
38 |
--------------------------------------------------------------------------------
/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))
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # docker-compose.yml
2 | services:
3 | booklogr-api:
4 | container_name: "booklogr-api"
5 | image: mozzo/booklogr:v1.6.0
6 | environment:
7 | - DATABASE_URL=sqlite:///books.db
8 | - AUTH_SECRET_KEY=this-really-needs-to-be-changed
9 | ports:
10 | - 5000:5000
11 | volumes:
12 | - ./data:/app/instance
13 |
14 | booklogr-web:
15 | container_name: "booklogr-web"
16 | image: mozzo/booklogr-web:v1.6.0
17 | environment:
18 | - BL_API_ENDPOINT=http://localhost:5000/ # CHANGE THIS TO POINT TO THE EXTERNAL ADRESS THE USER CAN ACCESS
19 | - BL_GOOGLE_ID= # Leave empty to disable google login. To enable, write in your Google Client ID.
20 | - BL_DEMO_MODE=false
21 | ports:
22 | - 5150:80
--------------------------------------------------------------------------------
/web/src/components/Data/DataTab.jsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import FileList from './FileList'
3 | import RequestData from './RequestData'
4 | import FileImport from './FileImport';
5 |
6 | function DataTab() {
7 | const [forceRefresh, setForceRefresh] = useState();
8 | return (
9 |
10 |
11 |
12 | setForceRefresh(true)}/>
13 |
14 |
15 |
16 |
17 | setForceRefresh(false)}/>
18 |
19 |
20 |
21 |
22 |
23 | )
24 | }
25 |
26 | export default DataTab
--------------------------------------------------------------------------------
/docs/docs/04 - Features/Demo-mode.md:
--------------------------------------------------------------------------------
1 | # Demo Mode
2 | BookLogr includes a demo mode feature for showcasing the applications functionality. This mode is used for the official demo site at https://demo.booklogr.app
3 |
4 | To try the official demo website, use the following login credentials:
5 | - **Username**: `demo@booklogr.app`
6 | - **Password**: `demo`
7 |
8 | ## Disabled features in demo mode
9 | The following features are disabled when in demo mode,
10 |
11 | - Account registration
12 | - Users cannot create new accounts. Everyone uses the shared demo account.
13 | - Google authentication
14 | - The Google sign-in option is removed from the login interface.
15 |
16 | ## Enabling Demo Mode
17 | If you want to enable demo mode for your own server, set the following environment variable for the `booklogr-web` service:
18 |
19 | ```env
20 | BL_DEMO_MODE=true
21 | ```
22 |
--------------------------------------------------------------------------------
/docs/docs/05 - Administration/Backup-and-restore.md:
--------------------------------------------------------------------------------
1 | # Backup and restore
2 | ## SQLite
3 | SQLite is a file that is stored where you point your `DATABASE_URL` env variable to. If you want to backup you simply copy the file to another folder.
4 |
5 | ## PostgreSQL
6 | ### Backup database
7 | ```
8 | sudo docker exec -t CONTAINER_ID pg_dumpall -c -U POSTGRES_USER > dump_`date +%Y-%m-%d"_"%H_%M_%S`.sql
9 | ```
10 |
11 | ### Restore
12 | :::info
13 | The database needs to be empty in order to be able to restore from backup. Stop all containers and delete the volume from the postgres service. Afterwords, only start up the postgres database. E.g `docker compose up booklogr-db -d`
14 | :::
15 |
16 | ```
17 | cat dump_2025-01-02_16_06_47.sql | sudo docker exec -i CONTAINER_ID psql -U POSTGRES_USER -d booklogr
18 | ```
19 | Lastly, start all other containers, `docker compose up -d`
20 |
21 |
22 |
--------------------------------------------------------------------------------
/docs/docs/Getting started.md:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 1
3 | ---
4 |
5 | # Getting started
6 |
7 | ## Prerequisites
8 | * Linux server (tested with Ubuntu 24.04)
9 | * [Docker](https://www.docker.com)
10 |
11 | ## Steps to set up the server
12 |
13 | ### 1. Create directory
14 | Create a directory to store `docker-compose.yml` file in and move to that directory.
15 | ```sh
16 | mkdir ./booklogr
17 | cd ./booklogr
18 | ```
19 |
20 | ### 2. Download docker-compose file
21 | Download `docker-compose.yml` file from the [repository](https://github.com/Mozzo1000/booklogr)
22 | ```sh
23 | curl --output docker-compose.yml "https://raw.githubusercontent.com/Mozzo1000/booklogr/refs/heads/main/docker-compose.yml"
24 | ```
25 |
26 | ### 3. Start the containers
27 | Start all containers
28 | ```sh
29 | docker compose up -d
30 | ```
31 |
32 | 🎉 Booklogr should now be available on http://localhost:5150
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "booklogr-api"
3 | version = "1.6.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.1.1"
13 | python-dotenv = "1.1.0"
14 | Flask-JWT-Extended = "4.7.1"
15 | flask-cors = "6.0.1"
16 | psycopg2 = "2.9.10"
17 | flask-marshmallow = "1.3.0"
18 | flask-sqlalchemy = "3.1.1"
19 | marshmallow-sqlalchemy = "1.4.2"
20 | Flask-Migrate = "4.1.0"
21 | gunicorn = "23.0.0"
22 | flasgger = "0.9.7.1"
23 | requests = "2.32.4"
24 | mastodon-py = "2.0.1"
25 | marshmallow = "4.0.0"
26 | flask-mail = "^0.10.0"
27 |
28 | [tool.poetry.group.dev.dependencies]
29 | pytest = "8.4.1"
30 |
31 | [build-system]
32 | requires = ["poetry-core"]
33 | build-backend = "poetry.core.masonry.api"
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Website
2 |
3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
4 |
5 | ## Installation
6 |
7 | ```bash
8 | yarn
9 | ```
10 |
11 | ## Local Development
12 |
13 | ```bash
14 | yarn start
15 | ```
16 |
17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
18 |
19 | ## Build
20 |
21 | ```bash
22 | yarn build
23 | ```
24 |
25 | This command generates static content into the `build` directory and can be served using any static contents hosting service.
26 |
27 | ## Deployment
28 |
29 | Using SSH:
30 |
31 | ```bash
32 | USE_SSH=true yarn deploy
33 | ```
34 |
35 | Not using SSH:
36 |
37 | ```bash
38 | GIT_USER= yarn deploy
39 | ```
40 |
41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
42 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/src/DateFormat.jsx:
--------------------------------------------------------------------------------
1 | export const formatDate = (date = {}) => {
2 | const displayLocale = localStorage.getItem("region") || navigator.language || "en-GB";
3 | const defaultOptions = {
4 | year: 'numeric',
5 | month: 'long',
6 | day: 'numeric',
7 | };
8 |
9 | return new Intl.DateTimeFormat(displayLocale, defaultOptions).format(date);
10 | };
11 |
12 | export const formatTime = (dateTime) => {
13 | const displayLocale = localStorage.getItem("region") || navigator.language || "en-GB";
14 | const defaultOptions = {
15 | hour: 'numeric',
16 | minute: '2-digit',
17 | hour12: localStorage.getItem("time_format_24h") === "true" ? false: true,
18 | timeZone: localStorage.getItem("timezone") || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
19 | };
20 |
21 | return new Intl.DateTimeFormat(displayLocale, defaultOptions).format(dateTime);
22 | };
23 |
24 | export const formatDateTime = (date) => {
25 | return `${formatDate(date)} ${formatTime(date)}`;
26 | };
--------------------------------------------------------------------------------
/docs/sidebars.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
4 |
5 | /**
6 | * Creating a sidebar enables you to:
7 | - create an ordered group of docs
8 | - render a sidebar for each doc of that group
9 | - provide next/previous navigation
10 |
11 | The sidebars can be generated from the filesystem, or explicitly defined here.
12 |
13 | Create as many sidebars as you want.
14 |
15 | @type {import('@docusaurus/plugin-content-docs').SidebarsConfig}
16 | */
17 | const sidebars = {
18 | // By default, Docusaurus generates a sidebar from the docs folder structure
19 | docsIDSidebar: [{type: 'autogenerated', dirName: '.'}],
20 |
21 | // But you can create a sidebar manually
22 | /*
23 | tutorialSidebar: [
24 | 'intro',
25 | 'hello',
26 | {
27 | type: 'category',
28 | label: 'Tutorial',
29 | items: ['tutorial-basics/create-a-document'],
30 | },
31 | ],
32 | */
33 | };
34 |
35 | export default sidebars;
36 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/migrations/versions/a1a14989ad81_add_created_on_column_to_books_table.py:
--------------------------------------------------------------------------------
1 | """Add created_on column to books table
2 |
3 | Revision ID: a1a14989ad81
4 | Revises: 8f8a46fec66b
5 | Create Date: 2025-07-19 19:44:37.593593
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'a1a14989ad81'
14 | down_revision = '8f8a46fec66b'
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('created_on', sa.DateTime(), 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('created_on')
31 |
32 | # ### end Alembic commands ###
33 |
--------------------------------------------------------------------------------
/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 | import { useTranslation, Trans } from 'react-i18next';
6 |
7 | function NotesIcon({id, notes, allowEditing, overrideNotes}) {
8 | const [openNotesModal, setOpenNotesModal] = useState();
9 | const { t } = useTranslation();
10 |
11 | return (
12 | <>
13 |
14 | setOpenNotesModal(true)} className="flex flex-row gap-2 items-center hover:bg-gray-100 hover:cursor-pointer dark:text-white dark:hover:bg-gray-500">
15 |
16 |
{notes}
17 |
18 |
19 |
20 | >
21 | )
22 | }
23 |
24 | export default NotesIcon
--------------------------------------------------------------------------------
/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 | if value.lower() in ["true", "yes", "y"]:
23 | return fn(*args, **kwargs)
24 | else:
25 | return jsonify({'error': 'Route disabled', 'message': 'This route has been disabled by the administrator.'}), 403
26 | return decorator
27 | return wrapper
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/api/commands/tasks.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | import click
3 | from getpass import getpass
4 | import sys
5 | import re
6 | from api.models import Tasks, db
7 |
8 | tasks_command = Blueprint('tasks_cli', __name__)
9 |
10 | @tasks_command.cli.command("clear")
11 | def clear_tasks():
12 | print("[CLEAR QUEUED TASKS]")
13 |
14 | all_tasks = Tasks.query.filter(Tasks.status=="fresh", Tasks.worker==None)
15 | if len(all_tasks.all()) > 0:
16 | confirmation = input(f"Are you sure you want to clear {len(all_tasks.all())} tasks? (y/N)")
17 |
18 | if confirmation == "y":
19 | all_tasks.delete()
20 | db.session.commit()
21 | print("Tasks cleared")
22 | else:
23 | print("Cancelled clearing tasks")
24 | sys.exit(1)
25 | else:
26 | print("There are no tasks to clear.")
27 | sys.exit(1)
28 |
29 | @tasks_command.cli.command("queue")
30 | def list_tasks():
31 | all_tasks = Tasks.query.filter(Tasks.status=="fresh").all()
32 | print(f"IN QUEUE: {len(all_tasks)}")
33 | for i in all_tasks:
34 | print(i.type)
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/docs/docs/04 - Features/Share-to-Mastodon.md:
--------------------------------------------------------------------------------
1 | # Share to Mastodon
2 | BookLogr has a Mastodon integration that will allow BookLogr to share events onto your Mastodon profile when they happen in realtime. For example when finishing a book, BookLogr will automatically toot out your progress.
3 |
4 | ## Enable via web interface
5 | By default, BookLogr will not share these events to Mastodon. You will first have to enable it in the Settings page on your BookLogr web interface.
6 |
7 | 
8 |
9 | ## Create app
10 | 1. Go to your Mastodon instance (ex https://mastodon.social)
11 | 2. Click the gear icon (Preferences) icon to access your Mastodon preferences.
12 | 3. Click Development in the left menu.
13 | 4. Click the New Application button.
14 | 5. Enter an Application Name of your choice (ex BookLogr).
15 | 6. Uncheck all the Scopes check boxes, and then check only the following check boxes: write:statuses.
16 | 7. Click the Submit button.
17 | 8. Click on the name of your application.
18 | 9. Copy your access token and paste it into BookLogr.
19 |
20 | ## References
21 | - [Issue opened on 26th of July 2024 - ActivityPub support?](https://github.com/Mozzo1000/booklogr/issues/17)
--------------------------------------------------------------------------------
/web/src/components/LanguageSwitcher.jsx:
--------------------------------------------------------------------------------
1 | import { Dropdown, DropdownItem } from 'flowbite-react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | function LanguageSwitcher({fullSize}) {
5 | const { i18n } = useTranslation();
6 | const languages = [
7 | {code: "en", label: "English", flag: "🇬🇧"},
8 | {code: "sv", label: "Svenska", flag: "🇸🇪"},
9 | {code: "ar", label: "عربي", flag: "🇸🇦"},
10 | {code: "cn", label: "中文", flag: "🇨🇳"}
11 | ]
12 |
13 | const currentLang = languages.find(lang => lang.code === i18n.language)?.flag || '🌐';
14 | const fullLang = languages.find(lang => lang.code === i18n.language)?.label;
15 |
16 | const changeLanguage = (lng) => {
17 | i18n.changeLanguage(lng);
18 | }
19 |
20 | return (
21 |
22 | {languages.map(({ code, label, flag }) => (
23 | changeLanguage(code)}>
24 | {flag} {label}
25 |
26 | ))}
27 |
28 | )
29 | }
30 |
31 | export default LanguageSwitcher
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/web/src/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import { Footer as FFooter, FooterCopyright, FooterLink, FooterLinkGroup, FooterIcon } from 'flowbite-react';
2 | import { IoLogoGithub } from "react-icons/io";
3 | import { DarkThemeToggle } from "flowbite-react";
4 | import LanguageSwitcher from './LanguageSwitcher';
5 | import CheckUpdate from './CheckUpdate';
6 |
7 | function Footer() {
8 | return (
9 |
10 |
11 |
12 |
13 |
v{import.meta.env.VITE_APP_VERSION}
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | export default Footer
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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);
--------------------------------------------------------------------------------
/api/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from datetime import timedelta
3 |
4 | class Config:
5 | CSRF_ENABLED = True
6 | SECRET_KEY = os.environ.get("AUTH_SECRET_KEY", "this-really-needs-to-be-changed")
7 | SQLALCHEMY_TRACK_MODIFICATIONS = False
8 | SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "postgresql://admin:password@localhost/booklogr")
9 | JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=5)
10 | EXPORT_FOLDER = os.environ.get("EXPORT_FOLDER", "export_data")
11 | GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", None)
12 | GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", None)
13 |
14 | ALLOWED_EXTENSIONS = {"csv"}
15 |
16 | SWAGGER = {
17 | "openapi": "3.0.0",
18 | "info": {
19 | "title": "BookLogr API",
20 | "description": "API for accessing BookLogr",
21 | "contact": {
22 | "url": "https://github.com/mozzo1000/booklogr",
23 | },
24 | "version": "1.0.0"
25 | },
26 | "components": {
27 | "securitySchemes": {
28 | "bearerAuth": {
29 | "type": "http",
30 | "scheme": "bearer",
31 | "bearerFormat": "JWT" # optional, arbitrary value for documentation purposes
32 | }
33 | }
34 |
35 | },
36 | "specs_route": "/docs"
37 | }
--------------------------------------------------------------------------------
/docker-compose.postgres.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.6.0
22 | depends_on:
23 | booklogr-db:
24 | condition: service_healthy
25 | env_file:
26 | - .env
27 | ports:
28 | - 5000:5000
29 |
30 | booklogr-web:
31 | container_name: "booklogr-web"
32 | image: mozzo/booklogr-web:v1.6.0
33 | environment:
34 | - BL_API_ENDPOINT=http://localhost:5000/ # CHANGE THIS TO POINT TO THE EXTERNAL ADRESS THE USER CAN ACCESS
35 | - BL_GOOGLE_ID= # CHANGE THIS TO YOUR OWN GOOGLE ID
36 | - BL_DEMO_MODE=false
37 | ports:
38 | - 5150:80
39 |
40 | volumes:
41 | booklogr:
42 | auth_db_vol:
--------------------------------------------------------------------------------
/web/src/services/books.service.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import authHeader from "./auth-header";
3 | import { getAPIUrl } from "./api.utils";
4 |
5 | const add = (data) => {
6 | return axios.post(getAPIUrl("v1/books"), data, { headers: authHeader() })
7 | };
8 |
9 | const get = (status, sort, order, page) => {
10 | if (status) {
11 | return axios.get(getAPIUrl(`v1/books?status=${status}&sort_by=${sort}&order=${order}&offset=${page}`), { headers: authHeader() })
12 |
13 | } else {
14 | return axios.get(API_URL + "v1/books", { headers: authHeader() })
15 |
16 | }
17 | }
18 |
19 | const edit = (id, data) => {
20 | return axios.patch(getAPIUrl(`v1/books/${id}`), data, { headers: authHeader() });
21 | };
22 |
23 | const remove = (id) => {
24 | return axios.delete(getAPIUrl(`v1/books/${id}`), { headers: authHeader() });
25 | };
26 |
27 | const notes = (id) => {
28 | return axios.get(getAPIUrl(`v1/books/${id}/notes`), { headers: authHeader() })
29 | };
30 |
31 | const addNote = (id, data) => {
32 | return axios.post(getAPIUrl(`v1/books/${id}/notes`), data, { headers: authHeader() })
33 | };
34 |
35 | const status = (isbn) => {
36 | return axios.get(getAPIUrl(`v1/books/${isbn}`), {headers: authHeader() })
37 | }
38 |
39 | export default {
40 | add,
41 | get,
42 | edit,
43 | remove,
44 | notes,
45 | addNote,
46 | status,
47 | };
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "docs",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "docusaurus": "docusaurus",
7 | "start": "docusaurus start",
8 | "build": "docusaurus build",
9 | "swizzle": "docusaurus swizzle",
10 | "deploy": "docusaurus deploy",
11 | "clear": "docusaurus clear",
12 | "serve": "docusaurus serve",
13 | "write-translations": "docusaurus write-translations",
14 | "write-heading-ids": "docusaurus write-heading-ids",
15 | "gen-api-docs": "docusaurus gen-api-docs all"
16 | },
17 | "dependencies": {
18 | "@docusaurus/core": "3.8.1",
19 | "@docusaurus/preset-classic": "3.8.1",
20 | "@mdx-js/react": "^3.0.0",
21 | "clsx": "^2.0.0",
22 | "docusaurus-plugin-openapi-docs": "^4.5.1",
23 | "docusaurus-theme-openapi-docs": "^4.5.1",
24 | "prism-react-renderer": "^2.3.0",
25 | "react": "^19.0.0",
26 | "react-dom": "^19.0.0"
27 | },
28 | "devDependencies": {
29 | "@docusaurus/module-type-aliases": "3.8.1",
30 | "@docusaurus/types": "3.8.1"
31 | },
32 | "browserslist": {
33 | "production": [
34 | ">0.5%",
35 | "not dead",
36 | "not op_mini all"
37 | ],
38 | "development": [
39 | "last 3 chrome version",
40 | "last 3 firefox version",
41 | "last 5 safari version"
42 | ]
43 | },
44 | "engines": {
45 | "node": ">=18.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-docs.yml:
--------------------------------------------------------------------------------
1 | name: Deploy docs site
2 |
3 | on:
4 | workflow_dispatch:
5 | jobs:
6 | build:
7 | name: Build Docusaurus
8 | runs-on: ubuntu-latest
9 | defaults:
10 | run:
11 | working-directory: ./docs
12 | steps:
13 | - uses: actions/checkout@v4
14 | with:
15 | fetch-depth: 0
16 | - uses: actions/setup-node@v4
17 | with:
18 | node-version: 18
19 | cache: npm
20 | cache-dependency-path: ./docs/package-lock.json
21 |
22 | - name: Install dependencies
23 | run: npm ci
24 | - name: Build website
25 | run: npm run build
26 |
27 | - name: Upload Build Artifact
28 | uses: actions/upload-pages-artifact@v3
29 | with:
30 | path: docs/build
31 |
32 |
33 | deploy:
34 | name: Deploy to GitHub Pages
35 | needs: build
36 |
37 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment
38 | permissions:
39 | pages: write # to deploy to Pages
40 | id-token: write # to verify the deployment originates from an appropriate source
41 |
42 | # Deploy to the github-pages environment
43 | environment:
44 | name: github-pages
45 | url: ${{ steps.deployment.outputs.page_url }}
46 |
47 | runs-on: ubuntu-latest
48 | steps:
49 | - name: Deploy to GitHub Pages
50 | id: deployment
51 | uses: actions/deploy-pages@v4
52 |
--------------------------------------------------------------------------------
/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('fk_books_owner_id', '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 |
--------------------------------------------------------------------------------
/web/src/components/Navbar.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Navbar, NavbarBrand, NavbarToggle, NavbarCollapse, NavbarLink } from 'flowbite-react'
3 | import { Link, useLocation } from 'react-router-dom';
4 | import { useTranslation } from 'react-i18next';
5 |
6 | const customThemeNav = {
7 | root: {
8 | base: "bg-[#FDFCF7] dark:bg-[#121212] px-2 py-2.5 dark:border-gray-700 dark:bg-gray-800 sm:px-4",
9 | },
10 | link: {
11 | active: {
12 | on: "bg-cyan-700 underline text-white dark:text-white md:bg-transparent md:text-cyan-700"
13 | }
14 | }
15 | };
16 |
17 | function NavigationMenu() {
18 | let location = useLocation();
19 | const { t } = useTranslation();
20 |
21 | return (
22 | <>
23 |
24 |
25 |
26 |
27 | BookLogr
28 |
29 |
30 |
31 |
32 | {t("forms.login")}
33 |
34 |
35 | {t("forms.register")}
36 |
37 |
38 |
39 |
40 | >
41 | )
42 | }
43 |
44 | export default NavigationMenu
--------------------------------------------------------------------------------
/web/src/components/RegionSwitcher.jsx:
--------------------------------------------------------------------------------
1 | import { Dropdown, DropdownItem } from 'flowbite-react';
2 | import { useState } from 'react';
3 |
4 | function RegionSwitcher() {
5 | const [selectedRegion, setSelectedRegion] = useState(localStorage.getItem('region') || navigator.language || 'en-GB');
6 |
7 | const availableRegions = [
8 | { code: 'en-US', name: 'United States', flag: '🇺🇸' },
9 | { code: 'en-GB', name: 'United Kingdom', flag: '🇬🇧' },
10 | { code: 'sv-SE', name: 'Sverige', flag: '🇸🇪'},
11 | { code: 'en-CA', name: 'Canada', flag: '🇨🇦' },
12 | { code: 'es-ES', name: 'España', flag: '🇪🇸' },
13 | { code: 'es-MX', name: 'México', flag: '🇲🇽' },
14 | { code: 'fr-FR', name: 'France', flag: '🇫🇷' },
15 | { code: 'de-DE', name: 'Deutschland', flag: '🇩🇪' },
16 | { code: 'it-IT', name: 'Italia', flag: '🇮🇹' },
17 | { code: 'pt-BR', name: 'Brasil', flag: '🇧🇷' },
18 | { code: 'nl-NL', name: 'Nederland', flag: '🇳🇱'},
19 | ];
20 |
21 | const currentRegion = availableRegions.find(r => r.code === selectedRegion);
22 |
23 | const changeRegion = (reg) => {
24 | setSelectedRegion(reg)
25 | localStorage.setItem("region", reg)
26 | }
27 |
28 | return (
29 |
30 | {availableRegions.map(({ code, name, flag }) => (
31 | changeRegion(code)}>
32 | {flag} {name}
33 |
34 | ))}
35 |
36 | )
37 | }
38 |
39 | export default RegionSwitcher
--------------------------------------------------------------------------------
/docs/docs/03 - Configuration/Email-verification.md:
--------------------------------------------------------------------------------
1 | # Configure Email Verification
2 | BookLogr supports email-based verification of new accounts. When a user signs up, they will receive a verification code via email if this feature is enabled. The user then needs to use this code to verify their account before being able to login.
3 | :::note
4 | This feature is **disabled** by default.
5 | :::
6 | To enable email verification, set the following environment variables in the `booklogr-api` container:
7 |
8 |
9 | | Variable | Description | Required | Default |
10 | |------------------|------------------------------------------|----------|-------------|
11 | | `AUTH_REQUIRE_VERIFICATION` | Enable verification of new accounts | Yes | `False` |
12 | | `MAIL_SERVER` | SMTP server address | Yes | — |
13 | | `MAIL_USERNAME` | Username for SMTP authentication | Yes | — |
14 | | `MAIL_PASSWORD` | Password for SMTP authentication | Yes | — |
15 | | `MAIL_SENDER` | Default sender email address | Yes | — |
16 | | `MAIL_PORT` | SMTP server port | No | `587` |
17 | | `MAIL_USE_TLS` | Use TLS encryption | No | `True` |
18 | :::tip
19 | See [Environment variables](/docs/Configuration/Environment-variables) for more information.
20 | :::
21 |
22 | ### Example `.env` configuration
23 |
24 | ```env
25 | AUTH_REQUIRE_VERIFICATION=True
26 | MAIL_SERVER=smtp.example.com
27 | MAIL_USERNAME=my_smtp_username
28 | MAIL_PASSWORD=my_smtp_password
29 | MAIL_SENDER=noreply@example.com
30 | MAIL_PORT=587
31 | MAIL_USE_TLS=True
32 | ```
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "private": true,
4 | "version": "1.6.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "cross-env VITE_APP_VERSION=$npm_package_version vite",
8 | "build": "cross-env VITE_APP_VERSION=$npm_package_version vite build",
9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview",
11 | "postinstall": "flowbite-react patch"
12 | },
13 | "dependencies": {
14 | "@react-oauth/google": "0.12.2",
15 | "@tailwindcss/postcss": "^4.1.10",
16 | "axios": "^1.10.0",
17 | "compare-versions": "^6.1.1",
18 | "flowbite-react": "0.11.8",
19 | "framer-motion": "^12.18.1",
20 | "i18next": "^25.3.2",
21 | "i18next-browser-languagedetector": "^8.2.0",
22 | "lodash": "^4.17.21",
23 | "react": "19.1.0",
24 | "react-confetti": "6.4.0",
25 | "react-dom": "19.1.0",
26 | "react-i18next": "^15.6.0",
27 | "react-icons": "^5.5.0",
28 | "react-image": "^4.1.0",
29 | "react-loading-skeleton": "^3.5.0",
30 | "react-markdown": "^10.1.0",
31 | "react-router-dom": "7.6.2",
32 | "remark-gfm": "^4.0.1"
33 | },
34 | "devDependencies": {
35 | "@tailwindcss/vite": "^4.1.10",
36 | "@types/react": "^19.1.8",
37 | "@types/react-dom": "^19.1.6",
38 | "@vitejs/plugin-react": "4.5.2",
39 | "autoprefixer": "10.4.21",
40 | "cross-env": "^7.0.3",
41 | "eslint": "^9.17.0",
42 | "eslint-plugin-react": "^7.37.3",
43 | "eslint-plugin-react-hooks": "^5.1.0",
44 | "eslint-plugin-react-refresh": "^0.4.16",
45 | "flowbite-typography": "1.0.5",
46 | "postcss": "8.5.6",
47 | "tailwindcss": "^4.1.10",
48 | "vite": "6.3.5"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/create-task.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: create-task
3 | title: "Create task"
4 | description: "Create task"
5 | sidebar_label: "Create task"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJxdUsGOmzAQ/RX0zlYgPfqWVqq0VaVG2lQ9IA4D8QYLsKk9RM1a/vdqCGl394THnvfmvTckBBNn76KJ0Amfqko+ZxO7YGe23kHjRHEoumCIzXmHnBWi6ZZg+QZdJ7SGggmHhXvousmNAtMlQtcrMKJRH/i+rFQFUxygMBnu/Rkas48MhZmECOV1X/KK/z/uuevNdBf6duij+urDRAyNb79Oglq7obdXKPBtlrpnnlcb1r14gXfeMXUsxyWMW0fUZXmx3C/trvNTOfnXV7+vqqpsvR9GfwnIH40djk/Fiw8FdZ2J0bpL8dn74bv0KrDlUaY/rorD8QkKVxPiHb3fVbtKSCWIiZzIcbQ6eJ/Yu6Hp4YrNHy7nkawTjtVH2sKscd2Lgsc6ekla10ippWh+hjFnuf69mCArbRSuFCy1ordu8psdHX88S7S05b6N/hfwdpC/YnsidxOTNC5SpXTvOPnBuJyhMJibaJcauck5/wVRPN0A
9 | sidebar_class_name: "post api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Create task
40 |
41 |
44 |
45 |
46 |
47 |
51 |
52 |
53 |
54 |
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/web/src/pages/Settings.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { Tabs, TabItem} 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 | import { useTranslation, Trans } from 'react-i18next';
11 | import { RiSlideshowView } from "react-icons/ri";
12 | import InterfaceTab from '../components/InterfaceTab';
13 |
14 | function Settings() {
15 | const [activeTab, setActiveTab] = useState(0);
16 | const { t } = useTranslation();
17 |
18 | return (
19 |
20 |
21 |
22 | {t("navigation.settings")}
23 |
24 |
setActiveTab(tab)} variant="underline" className="pt-1">
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default Settings
--------------------------------------------------------------------------------
/api/book_template_export.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 | Notes
37 |
38 | {% for item in data %}
39 |
40 | {{ item.title }}
41 | {{ item.author }}
42 | {{ item.description }}
43 | {{ item.isbn }}
44 | {{ item.reading_status }}
45 | {{ item.total_pages }}
46 | {{ item.current_page }}
47 | {{ item.rating }}
48 |
49 | {% for note in item.notes %}
50 | Content: {{ note.content }}
51 | Quote page: {{ note.quote_page }}
52 | Visibility: {{ note.visibility }}
53 | Created: {{ note.created_on }}
54 |
55 | {% endfor %}
56 |
57 |
58 | {% endfor %}
59 |
60 |
61 |
--------------------------------------------------------------------------------
/web/src/services/auth.service.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import globalRouter from "../GlobalRouter";
3 | import { getAPIUrl } from "./api.utils";
4 |
5 | axios.interceptors.response.use((response) => {
6 | return response
7 | }, async (error) => {
8 | if (error.response.status === 401) {
9 | logout();
10 | globalRouter.navigate("/login");
11 | }
12 | return Promise.reject(error)
13 | });
14 |
15 | const register = (email, name, password) => {
16 | return axios.post(getAPIUrl("/v1/register"), {
17 | email,
18 | name,
19 | password,
20 | });
21 | };
22 |
23 | const login = (email, password) => {
24 | return axios
25 | .post(getAPIUrl("/v1/login"), {
26 | email,
27 | password,
28 | })
29 | .then((response) => {
30 | if (response.data.access_token) {
31 | localStorage.setItem("auth_user", JSON.stringify(response.data));
32 | }
33 |
34 | return response.data;
35 | });
36 | };
37 |
38 | const loginGoogle = (code) => {
39 | return axios.post(getAPIUrl("/v1/authorize/google"), {code}).then((response) => {
40 | if (response.data.access_token) {
41 | localStorage.setItem("auth_user", JSON.stringify(response.data));
42 | }
43 | return response.data
44 | })
45 | }
46 |
47 | const logout = () => {
48 | /*TODO: Send logout request to auth-server so the token get invalidated. */
49 | localStorage.removeItem("auth_user");
50 | };
51 |
52 | const getCurrentUser = () => {
53 | return JSON.parse(localStorage.getItem("auth_user"));
54 | };
55 |
56 | const verify = (email, code) => {
57 | return axios.post(getAPIUrl("/v1/verify"), {
58 | email,
59 | code,
60 | });
61 | };
62 |
63 | export default {
64 | register,
65 | login,
66 | logout,
67 | getCurrentUser,
68 | verify,
69 | loginGoogle,
70 | };
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/get-list-of-files.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: get-list-of-files
3 | title: "Get list of files"
4 | description: "Get list of files"
5 | sidebar_label: "Get list of files"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJxtUsFu2zAM/RWBZ8F2hp58y4C16DAUxdZhB8MHWaFtIbboSnSw1NC/D0zsoVl3kkg9PvI9aoGAcSIfMUK5wKeikOOA0QY3sSMPJXxHnoOPanCRFbWqdQPGDJKGu+LuI/yJrghlaR4OqkHV0uwPGaSkIaKdg+MzlNUCDZqAYT9zD2VVp1oDmy5CWcG9EECt/6F+QL6dAjSMyD0doIQOGTRMRtggP+3yDbH1/GF7HK8633feonsKo2Eo4euvF6m6oKFcX0EDnyeJe+bposX5lqTckmdjWa5zGFZELPO8c9zPTWZpzEd6e6NdURR5Q3QcqAvi3626/fOjaikoYy3G6HynPhMdvwlWAzsepPuWUvvnR9BwwhCv1busyAohnSjyaLyM481Fwf9su2m9bNoYf3M+DcZ5YbqoWVZLKzjtQEO7baanyJJdlsZE/BmGlCT9OmOQ7dYaTiY408jUVZ3eLerhi/hrVvPXzn9dXi/yP9Yn48+i1AyzRMtyRbzQEX1KoOGIZxldYkh1SukPW0bzWQ==
9 | sidebar_class_name: "get api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Get list of files
40 |
41 |
44 |
45 |
46 |
47 |
51 |
52 |
53 |
54 |
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/.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 | - name: Set up QEMU
25 | uses: docker/setup-qemu-action@v3
26 |
27 | # setup Docker buld action
28 | - name: Set up Docker Buildx
29 | id: buildx
30 | uses: docker/setup-buildx-action@v3
31 |
32 | - name: Login to DockerHub
33 | uses: docker/login-action@v3
34 | with:
35 | username: ${{ secrets.DOCKER_HUB_USR }}
36 | password: ${{ secrets.DOCKER_HUB_TOKEN }}
37 |
38 | # Extract metadata (tags, labels) for Docker
39 | # https://github.com/docker/metadata-action
40 | - name: Extract Docker metadata
41 | id: meta
42 | uses: docker/metadata-action@v5
43 | with:
44 | images: |
45 | docker.io/mozzo/booklogr
46 | tags: |
47 | # set latest tag for default branch
48 | type=raw,value=latest,enable={{is_default_branch}}
49 | type=raw,value=${{ github.event.inputs.version }}
50 |
51 | - name: Build image and push to Docker Hub and GitHub Container Registry
52 | uses: docker/build-push-action@v6
53 | with:
54 | context: .
55 | platforms: linux/amd64,linux/arm64
56 | push: true
57 | tags: ${{ steps.meta.outputs.tags }}
58 | labels: ${{ steps.meta.outputs.labels }}
59 |
60 | - name: Image digest
61 | run: echo ${{ steps.docker_build.outputs.digest }}
62 |
--------------------------------------------------------------------------------
/docs/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Link from '@docusaurus/Link';
3 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
4 | import Layout from '@theme/Layout';
5 | import HomepageFeatures from '@site/src/components/HomepageFeatures';
6 |
7 | import Heading from '@theme/Heading';
8 | import styles from './index.module.css';
9 |
10 | function HomepageHeader() {
11 | const {siteConfig} = useDocusaurusContext();
12 | return (
13 | <>
14 |
36 |
37 | >
38 | );
39 | }
40 |
41 | export default function Home() {
42 | const {siteConfig} = useDocusaurusContext();
43 | return (
44 |
47 |
48 |
49 |
50 |
51 |
52 |
And many more features
53 |
56 | Learn more
57 |
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/.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 | - name: Set up QEMU
25 | uses: docker/setup-qemu-action@v3
26 |
27 | # setup Docker buld action
28 | - name: Set up Docker Buildx
29 | id: buildx
30 | uses: docker/setup-buildx-action@v3
31 |
32 | - name: Login to DockerHub
33 | uses: docker/login-action@v3
34 | with:
35 | username: ${{ secrets.DOCKER_HUB_USR }}
36 | password: ${{ secrets.DOCKER_HUB_TOKEN }}
37 |
38 | # Extract metadata (tags, labels) for Docker
39 | # https://github.com/docker/metadata-action
40 | - name: Extract Docker metadata
41 | id: meta
42 | uses: docker/metadata-action@v5
43 | with:
44 | images: |
45 | docker.io/mozzo/booklogr-web
46 | tags: |
47 | # set latest tag for default branch
48 | type=raw,value=latest,enable={{is_default_branch}}
49 | type=raw,value=${{ github.event.inputs.version }}
50 |
51 | - name: Build image and push to Docker Hub and GitHub Container Registry
52 | uses: docker/build-push-action@v6
53 | with:
54 | context: web
55 | platforms: linux/amd64,linux/arm64
56 | push: true
57 | tags: ${{ steps.meta.outputs.tags }}
58 | labels: ${{ steps.meta.outputs.labels }}
59 |
60 | - name: Image digest
61 | run: echo ${{ steps.docker_build.outputs.digest }}
62 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/get-profile-of-the-logged-in-user.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: get-profile-of-the-logged-in-user
3 | title: "Get profile of the logged in user"
4 | description: "Get profile of the logged in user"
5 | sidebar_label: "Get profile of the logged in user"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJyNUk1r3DAQ/StizsL2lpx820IStoSw0IQejA+yd2yLtTWuNF6yMfrvZbzedNNC6Ul6mq/33mgGj2EkFzBAPsOXLJPjgKH2dmRLDnLYuYb8YAQpU9HEijtUo6fG9qioWWBPbYsHZZ2aAvoEooa77O7vZs/0UdnQ5A4JxKghYD15y2fIixkqNB79duIO8qKMpQY2bYC8gP2lMkCp/2j7iPxvRqBhQO7oADm0yKBhNDIA0tMmHa99fzP5Xnc4XEy55XNFD4sjkMO3Hy9StWRDvkZBA59HwR3zuCi0riEpr8mxqVmuk+/XjJCnaWu5m6qkpiEd6P2dNlmWpRXRsafWi52fBW/3O9WQV6auMQTrWvWV6PgkuRrYci/Tr09qu9+BhhP6cKneJFmSSdORAg/GCR1nFgX/4+QnKvNVK+Mbp2NvrJPOi7p5dbmA00Ysv9lfR4ElMM+VCfjq+xjl+eeEXr5BqeFkvDWVCCnKeLO+x3ux3Kz7WId/GL9e5COtIePOIt70k6B5vmS80BFdjKDhiGdhLxhiGWP8Bds5C6s=
9 | sidebar_class_name: "get api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Get profile of the logged in user
40 |
41 |
44 |
45 |
46 |
47 |
51 |
52 |
53 |
54 |
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/api/commands/user.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 | import click
3 | from getpass import getpass
4 | import sys
5 | import re
6 | from api.auth.models import User, Verification
7 |
8 | user_command = Blueprint('user', __name__)
9 |
10 | def _validate_email(ctx, param, value):
11 | if not re.match(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", value):
12 | raise click.UsageError('Incorrect email address given')
13 | else:
14 | return value
15 |
16 | def _validate_role(ctx, param, value):
17 | valid_roles = ["user", "admin"]
18 | if value in valid_roles:
19 | return value
20 | else:
21 | raise click.UsageError(f"Incorrect role given, valid roles are: {valid_roles}")
22 |
23 |
24 | @user_command.cli.command("create")
25 | @click.argument('email', type=str, callback=_validate_email)
26 | @click.argument('name', type=str)
27 | @click.argument('role', type=str, callback=_validate_role)
28 | @click.option("--password", type=str)
29 | def create_user(email, name, role, password):
30 | print("Creating user..")
31 | if password:
32 | set_password = password
33 | else:
34 | password = getpass("Set a password: ")
35 | password_again = getpass("Confirm password: ")
36 | if password == password_again:
37 | set_password = password
38 | else:
39 | print("Passwords do not match, try again.")
40 | sys.exit(1)
41 | existing_user = User.query.filter(User.email==email).first()
42 | if existing_user:
43 | print(f"User with email {email} already exists.")
44 | sys.exit(1)
45 |
46 | new_user = User(email=email, password=User.generate_hash(set_password), name=name, role=role)
47 |
48 | try:
49 | new_user.save_to_db()
50 | try:
51 | new_verification = Verification(user_id=new_user.id, status="verified", code=None, code_valid_until=None)
52 | new_verification.save_to_db()
53 | except:
54 | print("Could not create user because of failure with verification")
55 | print(f"Successfully created a new user:\n\tEmail: {email}\n\tName: {name}\n\tRole: {role}")
56 | except:
57 | print("Could not save new user to database.")
58 |
--------------------------------------------------------------------------------
/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 | from datetime import datetime, timezone
7 |
8 | settings_endpoint = Blueprint('settings', __name__)
9 |
10 | @settings_endpoint.route("/v1/settings", methods=["GET"])
11 | @jwt_required()
12 | def get_settings():
13 | claim_id = get_jwt()["id"]
14 | user_settings_schema = UserSettingsSchema(many=False)
15 | user_settings = UserSettings.query.filter(UserSettings.owner_id==claim_id).first()
16 | if user_settings:
17 | return jsonify(user_settings_schema.dump(user_settings))
18 | else:
19 | return jsonify({
20 | "error": "Not found",
21 | "message": "User settings not found"
22 | }), 404
23 |
24 | @settings_endpoint.route("/v1/settings", methods=["PATCH"])
25 | @jwt_required()
26 | def edit_settings():
27 | claim_id = get_jwt()["id"]
28 |
29 | user_settings = UserSettings.query.filter(UserSettings.owner_id==claim_id).first()
30 | if user_settings:
31 | if request.json:
32 | if "send_book_events" in request.json:
33 | user_settings.send_book_events = request.json["send_book_events"]
34 | if "mastodon_url" in request.json:
35 | user_settings.mastodon_url = request.json["mastodon_url"]
36 | if "mastodon_access_token" in request.json:
37 | user_settings.mastodon_access_token = request.json["mastodon_access_token"]
38 |
39 | user_settings.updated_on = datetime.now(timezone.utc)
40 | user_settings.save_to_db()
41 | return jsonify({'message': 'User settings updated'}), 200
42 | else:
43 | return jsonify({
44 | "error": "Bad request",
45 | "message": "send_book_events, mastodon_url and/or mastodon_access_token not given."
46 | }), 40
47 | else:
48 | return jsonify({
49 | "error": "Not found",
50 | "message": "User settings not found."
51 | }), 404
--------------------------------------------------------------------------------
/docs/docs/07 - API/delete-note.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: delete-note
3 | title: "Delete note"
4 | description: "Delete note"
5 | sidebar_label: "Delete note"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJxtU8Fu2zAM/RXjnTZAiJ2hJ90yNAM6FEOxZdghyEGxmViILbkSHSw19O8DHSdtup4sUY98j4/0gM4E0xJTiNDrAdZBozNcQ8GZlqBhKygEeu5toAqaQ08KsaypNdAD+NSNKMe0p4CUNoKOnXeRogC+FIV8KoplsB1bLxQ/PFMWqPVHqrLYlyXFuOub5jRDUrgr7j5KyZxk7XzvqhlSUohU9sHyaZS+JRMoLHquodcbkcFmL12NZBEb9a7gPTXENBaFQktc+woa1RiGOtugkR/nuWBiPtgq4ZX1l3hw7vEt9+X2zYfWMDS+/1lhckycOr9CXZyrmbuxG+t2XtJL79iULMc+NBMi6jzfW6777az0bd76lxc/L4oi33p/aPw+iG+3/S2eHrKdD5kZ3bVun331/vAoWAW23Aj7JZQtnh6gcKQQz9nzWTErpGjnI7fGiZxpI26NuyG97gPTX867xlgnNcY+hsnRNY5zWa9xKgraVjKb2keWt2HYmki/Q5OShJ97CjLfjcLRBGu2ono9oLJRzhX0zjSR/pMhJpIT+z/9nHb3c/Zq+q28KWjcSSwwTS83KBzodP4B0ia92ZD75eNytYSCmQY+5V8nOx1E54elh+GMWPkDuZSuTCx3IUvpH7p0NU0=
9 | sidebar_class_name: "delete api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Delete note
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/get-tasks.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: get-tasks
3 | title: "Get tasks"
4 | description: "Get tasks"
5 | sidebar_label: "Get tasks"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJxtU8Fu2zAM/RWBpw0QYqfoSbcM2IoOPRRdhh2CHGSbsQXbkitRwVJD/z7QdtKk3ckS9cjH90iPMGiveyT0AdRuBGNBwaCpAQlW9wgKTAUSPL5G47ECRT6ihFA22GtQI9BpmFCWsEYPKe0ZHQZnAwYG3OU5fyoMpTcDGccUL0jR2yCMPTjfa44KXbhIghoUpEO7giThPr//nLvVoRWli10lrCNRoDi4aKsVpCQhYBm9odOkpkDt0W8iNaB2e+6MdM1CpxoB9vJD6QekiTyAhB6pcRUoqJFAzqYoyI7rbEJko6kSvBP+Ykdmxde059uPSSYo+PlnC4t/7Nv8CvLsY0M0TELYGU4vnSVdEh+j7xZEUFlWG2pisSpdn/Xu7c2t8zzPCufaztWezbuVtnl+FAfnhS5LDMHYWnxzrn1irAQy1DH7OSQ2z48g4Yg+zNnrVb7KuejgAvXacjvLflx7dkN52Q3Cv5QNnTaWK0wqxsXPHRzXzL/kK1PxUBoXiN/GsdABf/suJQ6/RvQ82L2Eo/ZGF9zzboTKBD5XoA66C/ipDbYQLZv/5WXZ46/i3fLb9pagtic2QHeRbyChxdP8M6R9ulqOh+88Tr3Mekm+DHU5cJP/rTuOM2LrWrQpXWiI78yU0j8fMjiC
9 | sidebar_class_name: "get api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Get tasks
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/download-file.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: download-file
3 | title: "Download file"
4 | description: "Download file"
5 | sidebar_label: "Download file"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJxtU8Fu2zAM/RWDpw0QYmfALr5lWDt02KHoWuwQ+KDYjC3EFl2KTpca+veBjpMmXU+mqCe+x0d6hN6y7VCQA+TrEZyHHHorDRjwtkPIYetanEIDjM+DY6wgFx7QQCgb7CzkI8ihV2wQdr6GGAsFh558wKD3X7JMPxWGkl0vjpTnAWVgHxJlWEA08PUj1JPfeXrxCTITJ1SWAzNWC4jRQMByYCeHSfsGLSOvBmkgXxcqQWytbcGtazFAYd5V/k4vviVbTQLAQIfSUAU51ChgjjbkkO6XqQJCOp6siPBG/Vs9ODZ5KeB0uiXurEAOP/88wuyYOnW8BXNyrhHpp5ac35I+L8mLLUXDgdsZEfI0rZ00w2ZRUpd29PpKyyzL0g3RrqWa1cXrJlf3d8mWOLFliSE4XyffiHa/FGtAnLTKfkolq/s7MLBHDsfXy0W2yLRoT0E661XOvBfv3buiPW+E4F9J+9Y6r1WmTsbZ2TXsl2Cm/QpgID8vWmGgoSCKGMeNDfjEbYyafh6QddiFgb1lZzeqfj1C5YLGFeRb2wb8T4yaiV7H8Olh3uHPyZv51yLnpPUHtcK2g57AwA4Pl79DLOLFyvy40fHaefZzifOQ50Clflh9HI+IR9qhj/FMJnpWphj/AT8DPME=
9 | sidebar_class_name: "get api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Download file
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/booklogr-api.info.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: booklogr-api
3 | title: "BookLogr API"
4 | description: "API for accessing BookLogr"
5 | sidebar_label: Introduction
6 | sidebar_position: 0
7 | hide_title: true
8 | custom_edit_url: null
9 | ---
10 |
11 | import ApiLogo from "@theme/ApiLogo";
12 | import Heading from "@theme/Heading";
13 | import SchemaTabs from "@theme/SchemaTabs";
14 | import TabItem from "@theme/TabItem";
15 | import Export from "@theme/ApiExplorer/Export";
16 |
17 |
21 |
22 |
23 |
28 |
29 |
30 |
31 |
32 | API for accessing BookLogr
33 |
34 |
37 |
43 |
46 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | Security Scheme Type:
60 |
61 | http
62 |
63 |
64 |
65 | HTTP Authorization Scheme:
66 |
67 | bearer
68 |
69 |
70 |
71 | Bearer format:
72 |
73 | JWT
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
84 |
87 | Contact
88 |
89 |
90 |
91 | URL: [https://github.com/mozzo1000/booklogr](https://github.com/mozzo1000/booklogr)
92 |
93 |
94 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/get-profile-by-name.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: get-profile-by-name
3 | title: "Get profile by name"
4 | description: "Get profile by name"
5 | sidebar_label: "Get profile by name"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJxtU01v2zAM/SsCTxsgxM7Qk24dsBUZdgi2DjsEwSDbtC3EFl2JDuYa+u8DE3tr2p0sko8f75GeYbDB9sgYIpjDDM6DgcFyCxq87REMVC4OnZ1+XUwNAZ9GF7ACw2FEDbFssbdgZuBpEHzk4HwDKR0FHAfyEaPEP+S5fCqMZXADO5JeO19T6K1YyhY0suIW1RCodh1uIGm4y+/epu2vAOWJVU2jrzaQkga2jfBYwxGO+lXiA/JaXRWTWkj1yC1VYKBBBn0VwEB23mYLNmbzSx0SaIhYjsHx9F0EuDIs0AYM96Nkr9bnCz0w8OXnIyxyiUzXKOhVtpZ5uHBwviZJL8mzLVmeY+gWRDRZ1jhux2JTUp/19PxM2zzPs4Lo1FETRLFbxvf7naopKFuWGKPzjfpIdPoqWA3suJPuq0vd73eg4YwhXrO3m3yTS9GBIvfWyzjLYfxfypvmf4+C8TdnQ2edl1oXPvMi8wHOW9F8XZkGc3NyRw0tRRbgPBc24o/QpSTupxHDBOZw1HC2wdlCqBxmuVh5V2Bq20V8M5Moi1528u7bcs3v1b9N3M66OK2fRBfbjWKBhhNOr3+OdEwvTunh0yOk9AemiSYS
9 | sidebar_class_name: "get api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Get profile by name
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/web/src/components/RemoveBookModal.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { DropdownItem, Modal, ModalBody, ModalHeader, Button } from 'flowbite-react'
3 | import BooksService from '../services/books.service';
4 | import { RiErrorWarningLine } from "react-icons/ri";
5 | import useToast from '../toast/useToast';
6 | import { useTranslation, Trans } from 'react-i18next';
7 |
8 | function RemoveBookModal(props) {
9 | const toast = useToast(4000);
10 | const { t } = useTranslation();
11 |
12 | const removeBook = () => {
13 | BooksService.remove(props.id).then(
14 | response => {
15 | toast("success", response.data.message);
16 | props.onSuccess();
17 | },
18 | error => {
19 | const resMessage =
20 | (error.response &&
21 | error.response.data &&
22 | error.response.data.message) ||
23 | error.message ||
24 | error.toString();
25 | toast("error", resMessage);
26 | }
27 | )
28 | props.close(false);
29 | }
30 |
31 | return (
32 |
33 |
props.close(false)} popup>
34 |
35 |
36 |
37 |
38 |
39 | {t("book.remove_confirmation.title")}
40 |
41 |
42 | removeBook()}>
43 | {t("book.remove_confirmation.yes")}
44 |
45 | props.close(false)}>
46 | {t("book.remove_confirmation.no")}
47 |
48 |
49 |
50 |
51 |
52 |
53 | )
54 | }
55 |
56 | export default RemoveBookModal
--------------------------------------------------------------------------------
/docs/docs/03 - Configuration/Google-sign-in.md:
--------------------------------------------------------------------------------
1 | # Configure Google Sign-In
2 |
3 | BookLogr supports Google authentication for account sign-in. To enable it, you will need to set up credentials and apply them to your container environment.
4 |
5 | ## Steps to set up Google Sign-In
6 | ### 1. Create OAuth Credentials
7 | 1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
8 | 2. Select or create a project.
9 | 3. Navigate to **APIs & Services** → **Credentials**.
10 | 4. Set up the OAuth consent screen.
11 | 5. Click **Create Credentials** → **OAuth 2.0 Client ID**.
12 | 6. Choose **Web application** and configure:
13 | - **Authorized JavaScript origin**:
14 | ```
15 | http://{your-domain-or-ip-of-web}
16 | ```
17 | - **Authorized redirect URI**:
18 | ```
19 | http://{your-domain-or-ip-of-api}/v1/authorize/google
20 | ```
21 | 7. Save and copy the **Client ID** and **Client Secret**.
22 |
23 | :::note
24 | Replace `{your-domain-or-ip-of-web}` with the user facing domain name or IP of your `booklogr-web` server/container.
25 |
26 | Replace `{your-domain-or-ip-of-api}` with the user facing domain name or IP of your `booklogr-api` server/container.
27 | :::
28 |
29 | ### 2. Configure Environment Variables
30 | Use your **Client ID** and **Client Secret** and set the environment variables accordingly.
31 | :::tip
32 | See [Environment variables](/docs/Configuration/Environment-variables) for more information.
33 | :::
34 |
35 | #### `booklogr-api` container
36 | ```env
37 | GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
38 | GOOGLE_CLIENT_SECRET=your-client-secret
39 | ```
40 |
41 | #### `booklogr-web` container
42 | ```env
43 | BL_GOOGLE_ID=your-client-id.apps.googleusercontent.com
44 | ```
45 | :::note
46 | If BL_GOOGLE_ID is empty or not set, the Sign in with Google button will not appear on the login screen.
47 | :::
48 |
49 | ## Troubleshooting
50 | ### Error: Google Sign-In Disabled
51 | If the Google Sign-In button is disabled this indicates that the provided Client ID is in the wrong format.
52 |
53 | **Verify Client ID Format**
54 |
55 | It should match this pattern:
56 | ```
57 | [numbers]-[string].apps.googleusercontent.com
58 | ```
59 | :::note
60 | If you Client ID does indeed match this pattern and you are still getting "Google Sign-In Disabled", please open an [issue](https://github.com/mozzo1000/booklogr/issues).
61 | :::
62 |
63 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/edit-profile.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: edit-profile
3 | title: "Edit profile"
4 | description: "Edit profile"
5 | sidebar_label: "Edit profile"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJx1U01v2zAM/SsBz1rsDD35lhYb1mGHYMuwQxAMss3YamxRleisqaH/PtAfadpuF1v8EPnI99SDx8cOA99SeYash4Iso2U5aucaU2g2ZJOHQFZ8oaix1XJynhx6NhjEKk1wjT7/trpFsfnsEDII7I2tICo4mWBy0xg+/yMc1eyh/AELhiguQWY8lpAddBNw8ARHNowtP6bp0BlD4Y0TlJDBxtPBNLjoXKkZy6W0vklv/p9oiRcH6qykRgUBi84PIHc95Kg9+nXHNWS7fdwrYF0FyHbz7QB79abup9Lwwo1hUNAi11RCBk5zUYOSfw0ZJKdV4uYiL21/yHrH8a6bz9Zn8q1myODrr63cGrIhm6Jw2WLN7IZxhLbvLwR/etKta/A9YTMTr3m6os/YA83y0MUgj843U6uQJUlluO7yZUFt0tLzM63SNE1yomNDlZcKr9e03twvDuQXuigwBGOrxS3R8ZvkKmDDAhJm12K9uRdo6MN4e7VMl6kUdRS41YMyp0He7P9V14vuGJ84cY02VooMg/QTMzs4rYSmK4JrCiyBvs91wJ++iVHcjx160clewUl7o3PBvNtHBTXqEv0goSPKHu/GR/VhK+0lvekExrsHJhK7KGaz3t59AQX59DJbKuWS139ADd8MQAENsw2KGXw9NNpWna4kdywqQtCTjqYFXAQzHQTqFNL2fAWx78eMLR3RxghqmojFhriPMf4FEMh16A==
9 | sidebar_class_name: "patch api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Edit profile
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/get-notes-from-book.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: get-notes-from-book
3 | title: "Get notes from book"
4 | description: "Get notes from book"
5 | sidebar_label: "Get notes from book"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJx1k09v2zAMxb+K8E4tIMTO0JNvLbYVGYauaDvsUOSg2EwsxJZciQ6WGvruA223a9buZP15JB9/ogd0JpiWmEJE8TigolgG27H1DgVWn5Xfqo33e3V28+NBre6vbs6hYeWyM1xDw5mWUMBW0Aj01NtAFQoOPWnEsqbWoBjAx25UOaYdBaS0FnXsvIsURfApz+VzWv+OuA8uKueZojLMpqypUuwV1zT6WiBpXOQX72NvxpjS900l8WpDaut7Vy2Qkkaksg+Wj2PTGzKBwmXPNYrHtVhjsxMeuPJ+H7HW/+S+Jp49bYNvRyPQaIlrX6HAjhh64lMgOywzEcRssFXKxjD8NXAviCYEb2287L760BpGgW+/HjADFZDTLfQL2Jq5GxuzbuslvPSOTcmy7EMzK2KRZTvLdb9ZlL7NWv/87Jd5no8GG78LQvO01cvbldr6oExZUozW7ZQw+S5aDbbcSPWXI3V5u4LGgUKcopeLfJFL0s5Hbo0TO/PAfMzwpPjr2DD95qxrjHWSa+xnmPk+4rCExkgYGsU4hxPktUbtI4tmGDYm0s/QpCTHTz0Fefq1xsEEazbShUy/jbKuUGxNE+mdHYFKTp7j7G4e9XP1v5/kQ+/zoXFH4WSaXnbQ2NNx+onSOr2ZpOsv8upmHok5+PXt54U4/zDvMEyKB78nl9JrGZa9VErpD7agVj4=
9 | sidebar_class_name: "get api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Get notes from book
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/web/public/fallback-cover.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | BOOK COVER NOT AVAILABLE
51 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/remove-book-from-list.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: remove-book-from-list
3 | title: "Remove book from list"
4 | description: "Remove book from list"
5 | sidebar_label: "Remove book from list"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJx1U01v2zAM/SsCTy0gxM7Qk2/pkgEZiq5oM+wQ5KDYdCxEllyJzuYa+u8DbTdr1vakr0e+x0eqh0Z5VSOhD5Bteygw5F43pJ2FDNZL4Uqxd+4oru5/bMT66fb+GiRofmwUVSDBqhohA12ABI/PrfZYQEa+RQkhr7BWkPVAXTOgLOEBPcS4Y3RonA0YGPAlTXm55L9lZo+1O2EhQpvnGELZGtPNIEq4SW/eh3x1rSmEdSRKbQuhbDfq/62pElShCG3TGI2FWC9nEKOEgHnrNXVD/XtUHv2ipQqy7Y5VkjqwNYOWADv5H9/joG7kKL2rhdGBQEKNVLkCMijQICHI0a8MktM8YXRIel1E+Mf/xGaNZrxV8Xr65nytCDL4/msDk7Vs6fgK8tXiiqgZ6tK2dByeO0sqJ9623kyIkCXJQVPV7me5q5Pavby4eZqmgzTjDp4Nvqx08bAWpfNCDW3Q9iDYkjvGSiBNBqeO8ZVYPKxBwgl9GKPns3SWctLGBaqVZTnT6Hxm4QX9eYQI/1DSGKUtZxsq6idvt3Cag4TBXZCQ6YL7VblA/Nb3exXwpzcx8vVzi557vpNwUl6rPevnH6AD7wvISmUCvpPBdqLlRlw9TuN+LT77KB9qni6V7dghZVo+gYQjduNHirv4ZoCWq7vVZgUS1DQPU/y58dOGxX+Yuu9HxMYd0cZ4ZiI+M1mMfwHlVlkm
9 | sidebar_class_name: "delete api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Remove book from list
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/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)
--------------------------------------------------------------------------------
/docs/src/css/custom.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Any CSS included here will be global. The classic template
3 | * bundles Infima by default. Infima is a CSS framework designed to
4 | * work well for content-centric websites.
5 | */
6 |
7 | /* You can override the default Infima variables here. */
8 | :root {
9 | background-color: #fff;
10 | --ifm-color-primary: #0891B2; /* Base cyan */
11 | --ifm-color-primary-dark: #067c9b; /* Slightly darker */
12 | --ifm-color-primary-darker: #056b86; /* More contrast */
13 | --ifm-color-primary-darkest: #04556b; /* Deepest tone */
14 | --ifm-color-primary-light: #0ba9cc; /* Slightly lighter */
15 | --ifm-color-primary-lighter: #FDFCF7;
16 | --ifm-color-primary-lightest: #a5e9f3;/* Very light, almost pastel */
17 | --ifm-code-font-size: 95%;
18 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.05);
19 |
20 | }
21 |
22 | /* For readability concerns, you should choose a lighter palette in dark mode. */
23 | [data-theme='dark'] {
24 | background-color: #20201D;
25 | --ifm-color-primary: #0891B2;
26 | --ifm-color-primary-dark: #155e75;
27 | --ifm-color-primary-darker: #164e63;
28 | --ifm-color-primary-darkest: #0f3a4a;
29 | --ifm-color-primary-light: #22b8cf;
30 | --ifm-color-primary-lighter: #67d4e3;
31 | --ifm-color-primary-lightest: #b2ebf2;
32 | --ifm-code-font-size: 95%;
33 | --docusaurus-highlighted-code-line-bg: rgba(255, 255, 255, 0.05);
34 | }
35 |
36 | /* typography.css */
37 | h1 {
38 | font-size: 3rem; /* 48px */
39 | line-height: 1.2; /* ~57.6px */
40 | font-weight: 700;
41 | }
42 |
43 | h2 {
44 | font-size: 2.25rem; /* 36px */
45 | line-height: 1.25; /* ~45px */
46 | font-weight: 600;
47 | }
48 |
49 | h3 {
50 | font-size: 1.875rem; /* 30px */
51 | line-height: 1.33; /* ~40px */
52 | font-weight: 600;
53 | }
54 |
55 | h4 {
56 | font-size: 1.5rem; /* 24px */
57 | line-height: 1.5; /* 36px */
58 | font-weight: 500;
59 | }
60 |
61 | h5 {
62 | font-size: 1.25rem; /* 20px */
63 | line-height: 1.5; /* 30px */
64 | font-weight: 500;
65 | }
66 |
67 | h6 {
68 | font-size: 1.125rem; /* 18px */
69 | line-height: 1.5; /* 27px */
70 | font-weight: 500;
71 | }
72 |
73 | p {
74 | font-size: 1rem; /* 16px */
75 | line-height: 1.5; /* 24px */
76 | }
77 |
78 | .center-both {
79 | display: flex;
80 | justify-content: center; /* horizontal */
81 | align-items: center; /* vertical */
82 | height: 200px; /* or any height */
83 | text-align: center;
84 | }
85 |
86 | p.lead {
87 | font-size: 1.5rem;
88 | font-weight: 300;
89 | margin: 20 0 20 0;
90 | }
--------------------------------------------------------------------------------
/docs/docs/07 - API/get-books-in-list.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: get-books-in-list
3 | title: "Get books in list"
4 | description: "Get books in list"
5 | sidebar_label: "Get books in list"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJy1U01r20AQ/SvLnFJYbLlH3VJoQkoPoUnpwZiwksbSEmlXmRmZOkL/vYwkJ45xA23pSZrdt++9+eqhdeQaFCSGdN2DD5DCU4e0BwvBNQgpsDjpGCwQPnWesIB062pGC5xX2DhIe5B9O0HJhxKGwfZQIOfkW/FROa881oWRaDiSmGxvLlqKJSGzNa4osHgonKA14qVGa1wnVSRryIkPpTWErvChfJi8WOM5Cx/AnvcbSR6y/T8avouk0iZSgWQuHOcmklHM73RH5J+pbhTNbQyMrPcfk0Q/b518Q+kosMlifGTjg6k9i1oGxrwjL/uxdRk6QrrspIJ0vVFqcaV2FT7pQ9jYE95rlBNOCw1KFQtIoUQNW6dssNytliMSXjXvNKnJ9bHyIbqK1DiBFL78uIe5BJr6dAv2UIpKpB1z8WEb9Xkeg7hc9LejekZwulyWXqouW+SxWTbx+TmukiQZXdWxJBhOs7u8vTHbSMblOTJrJ7UMXxVrYZwySOFwZC5vb8DCDomn16tFskiUtI0sjQtqZ27zubK9kX5ps+BPWba180GZxmz6uaRr2K3AQnboTBVZ9LTvM8f4neph0ONpwrS7hWeX1UdD9Yj7493cubpTTRgn+RR84k9rjEH+31qeLcDs+GU7/9Ly+4v5jvJhP191NxqQV+FxZ47m//qzjq2bZ3pmfRne+UcbM1+5cJxS30+I+/iIYRjgYEE0hmEzDMMvecPc9Q==
9 | sidebar_class_name: "get api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Get books in list
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/check-if-book-by-isbn-is-already-in-a-list.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: check-if-book-by-isbn-is-already-in-a-list
3 | title: "Check if book (by isbn) is already in a list."
4 | description: "Check if book (by isbn) is already in a list."
5 | sidebar_label: "Check if book (by isbn) is already in a list."
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJyVU8Fu2zAM/RWBpxYQYmfoSbd02IYMw1C0HXYIfJBtxhZiS65EZ3MN/ftA20mbrjvsZIkm+R4fn0botNctEvoAajdCiaHwpiPjLCjYPtx+F24vcucOIMFwrNNUgwSrWwQFJuQWJHh86o3HEhT5HiWEosZWgxqBhm7Ks4QVeogx4+zQORswcMKHNOXPJfA9Uu9tmICFKYW2pfhVaxKNCSQMCROEsSuIEm7Sm3/Xo/fOT9UthqArlMK6ueve9bYUxgptB+FRl8ZWU/sVxCghYNF7Q8OkSo7ao9/0VIPaZTwC6YoFg1vnDgEy+Qb/Y43FQZhZOXGVD4KFumbaumGwYUJe8CS0SLUrQUGFBHLWWEFyXCfcICQjl0d4ofXAAs8CviZ3un12vtUECr7+fIRlHbyG+S/I01pqom4a19i94/LCWdIF8bH3zZIRVJJUhuo+XxWuTVr3/OzWaZpO5BpXed7DpQCbu63Ys/RFgSGwtKzUN86VQIYaRj+FxOZuCxKO6MNcvV6lq5Sbdi5Qqy3TWQz3v8pe0DrbkfA3JV2jjWWUadJxUX0HxzVImHQHCWpyeCahdoH47zjmOuAP38TI4acePZskk3DU3uicJ+OHZAKfS1B73QT8iwgLjZZXdHW/PJ5r8ea9vUt2CWo7sGS66fkGEg44nN5jzOIrS335xBbQiz+W8rMRlgNTfrfzOM4Zj+6ANsYzEPGdkWL8A+xbbvY=
9 | sidebar_class_name: "get api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Check if book (by isbn) is already in a list.
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/create-profile-for-logged-in-user.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: create-profile-for-logged-in-user
3 | title: "Create profile for logged in user"
4 | description: "Create profile for logged in user"
5 | sidebar_label: "Create profile for logged in user"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJyNU02L2zAQ/Stmzm7slF7q2+7SwpZCQzelhxDK2J7Y2siSVpLTZI3+exnZ+XCXQi9GGs/He/OeBrD00pPz97o+QTFApZUn5fmIxkhRoRdaZc9OK465qqUO+WSsNmS9IMe3Wjgj8fRLYUd89ydDUIDzVqgGQgoH4UQppPBxSk077KWHAlpR16Qg/bsipBGZsFRDsZn3316ydflMlYcwT9+hdBQjzmjlRoTv83yc7CorDJOCAlZW74SkpLKEnuoFI/2Qf/x3IkpLWJ8SOgrn3SLidFT1NhLbDFASWrJ3vW+h2GwDQ8XGMYWphWP48+YPcXpiphk7bROpm4bqRKikd2QhhY58q2sowGjnIQWDPAGywzIz58ZXKE8s08j7FtD59lnbDnn7X36uuSpmQzH9vYrRem8iRZb/+9Uon47YGUlvhT/LN9f7LHJIQaidPtsMq2iz3spplCuyrBG+7ctFpbus06+vepnneVZqvZe6sdxhvrq71WPcF1YVOSdUk9xrvf/KuSl44RkknEPJ3eqRoZF1Y/VykS9ybspL7TA6fCLyP5rMoFw87+noMyNRRMKR3TDJtYHDkrW7cULLchYbGIYSHf2wMgQOv/Rk2VDbFA5oBZZMZLMNKbSENdnotT3xch/GF/tuzeM5XfYM483rZS9eXLT69sTKl9Or73TNNRZ/Qxq/BUAKOlKLLoqxASSqpseGc8eebA6cvDXxv5hoOjDS6Req0w3CYRgz1npPKgRIJ0Ke7xC2IYQ/mJ+Wyg==
9 | sidebar_class_name: "post api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Create profile for logged in user
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/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, mail
15 | from api.auth.user_route import user_endpoint
16 | from api.commands.tasks import tasks_command
17 | from api.commands.user import user_command
18 | from pathlib import Path
19 | import tomllib
20 | import os
21 | from flask_mail import Mail
22 | from dotenv import load_dotenv
23 |
24 | load_dotenv()
25 |
26 | app = Flask(__name__)
27 | CORS(app)
28 |
29 | app.config.from_object(Config)
30 | swagger = Swagger(app)
31 |
32 | db.init_app(app)
33 | ma.init_app(app)
34 | migrate = Migrate(app, db)
35 | jwt = JWTManager(app)
36 |
37 | if os.getenv("AUTH_REQUIRE_VERIFICATION", "false").lower() == "true":
38 | app.config.update(
39 | MAIL_SERVER = os.environ.get("MAIL_SERVER", None),
40 | MAIL_PORT = os.environ.get("MAIL_PORT", 587),
41 | MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS", True),
42 | MAIL_USERNAME = os.environ.get("MAIL_USERNAME", None),
43 | MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD", None),
44 | MAIL_DEFAULT_SENDER = os.environ.get("MAIL_SENDER", os.environ.get("MAIL_USERNAME")),
45 | )
46 | mail.init_app(app)
47 | else:
48 | mail = None
49 |
50 | if not os.path.exists(app.config["EXPORT_FOLDER"]):
51 | os.makedirs(app.config["EXPORT_FOLDER"])
52 |
53 | app.register_blueprint(tasks_command)
54 | app.register_blueprint(user_command)
55 |
56 | app.register_blueprint(books_endpoint)
57 | app.register_blueprint(profiles_endpoint)
58 | app.register_blueprint(notes_endpoint)
59 | app.register_blueprint(tasks_endpoint)
60 | app.register_blueprint(files_endpoint)
61 | app.register_blueprint(settings_endpoint)
62 |
63 | app.register_blueprint(auth_endpoint)
64 | app.register_blueprint(user_endpoint)
65 |
66 | @app.route("/")
67 | def index():
68 | version = "unknown"
69 | pyproject_toml_file = Path(__file__).parent.parent / "pyproject.toml"
70 | print(pyproject_toml_file)
71 | if pyproject_toml_file.exists() and pyproject_toml_file.is_file():
72 | with open(pyproject_toml_file, "rb") as f:
73 | version = tomllib.load(f)["tool"]["poetry"]["version"]
74 | return {
75 | "name": "booklogr-api",
76 | "version": version
77 | }
--------------------------------------------------------------------------------
/docs/docs/07 - API/edit-book.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: edit-book
3 | title: "Edit book"
4 | description: "Edit book"
5 | sidebar_label: "Edit book"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJx1lF9vmzAQwL+KdU+txAKZtofxlnadlmpqqzbVHqpoMnABN2BT+0ibIr77dIaQdMmeMOf7fz9fC7W0skJC6yB+aiFDl1pVkzIaYph/F2YlEmPW4uzmdiHmDxc35xCA4staUgEBaFkhxKAyCMDiS6MsZhCTbTAAlxZYSYhboG3ttTRhjha6btlro6MLk21ZJTWaUBMfZV2XKpWcRfjsOJX2wFdtTY2WFDpv1liLmv7UMsdTkQKwkpTO+W5lbCUJYliVRhIEO2XdVEmv60hS4w78OLJs3HWjskmeMSXoWLQveCVLh17iaqNdn9vnKOLPx6ZecDvTQuocM+GaNEXnVk1ZbiecwJfo27HJPaYKN5gJbcT1w+2NUFokJustvp4K8qjX2rxqgdYaK0zqu5RNfB0O08Yq2vqBJygt2llDBcRPSx4LyZxZ8Hk6WAb/eL7KFHkkIIAKqTBZz0LKMHgmYgg305BVXNiqrIN9yAceYt+bw8C7vx+7+Vz/XsCAD7e8v93PqyCqfSkMx/0eo6s3WdUlHmMR7SmI9kMeh8tEr8wOQpl6CBtbDqFcHIa5oqJJJqmpwsq8v5tpFEW+xtLknpyPXZrdzcXKWCH9eJXOBbfzF+sGQIo4SdiJxOxuDgFs0LreejqJJhE7rY2jSnr+h3d22P4PIUdiCd8orEupNHvwVbTDYJ5gM4UA/GgggFhlPN/COOK7tk2kw0dbdh2LXxq0zMgygI20SiacM68I5fi8Y/4ojfEdw9n98DzOxf82ycmcB6HUW+6KLBv+gwDWuO03TbfsAihQZmh9Sv3FZR/404LN94ZHy4QhH8m9my0uf/qe9FuoMhkbWfnK+0y+9pGNr85z62UtlFLnjWcLeqeMoxxoHgoYsR0OnOrJ2tq211iYNequG0sl/udqu+4v0Mbo6A==
9 | sidebar_class_name: "patch api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Edit book
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/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 | import { HR } from "flowbite-react";
5 | import { useTranslation, Trans } from 'react-i18next';
6 |
7 | function AccountTab() {
8 | const [disableSaveButton, setDisableSaveButton] = useState(true);
9 | const { t } = useTranslation();
10 |
11 | return (
12 |
13 |
14 | {t("forms.save")}
15 |
16 |
17 |
18 |
19 |
20 | {t("forms.upload_picture")}
21 | {t("forms.remove")}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
{t("forms.email")}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
{t("forms.change_password")}
42 |
43 |
44 |
45 | {t("forms.old_password")}
46 |
47 |
48 |
49 |
50 | {t("forms.new_password")}
51 |
52 |
53 |
54 |
55 | {t("forms.new_password_confirm")}
56 |
57 |
58 |
59 |
{t("forms.change_password")}
60 |
61 |
62 |
63 |
64 | )
65 | }
66 |
67 | export default AccountTab
--------------------------------------------------------------------------------
/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 | * Track your current page in the book you are reading.
38 | * Share a public profile of your library with others.
39 | * Rate books you have read using a 0.5 to 5-star scale.
40 | * Take short notes and save quotes from the books you read.
41 | * Automatically share your reading progress to Mastodon.
42 | * Export your data in multiple formats, including CSV, JSON, and HTML.
43 | * Supports SQLite (default) or PostgreSQL as databases.
44 |
45 | ## 🖥 Install
46 | BookLogr is made to be self-hosted and run on your own hardware.
47 |
48 | Check out the [Getting Started guide](https://booklogr.app/docs/Getting%20started) for step-by-step instructions.
49 |
50 | ## 🛠️Development
51 | See [development instructions](https://github.com/Mozzo1000/booklogr/wiki/Development) on the wiki to get started.
52 |
53 | ## 🙌Contributing
54 | All contributions, from bug reports to feature requests and code submissions are welcome!
55 |
56 | If you’d like to contribute translations:
57 | - Visit our [translation guide](https://booklogr.app/docs/Developer/Translation) to get started.
58 | - Submit translations via pull request or get in touch through an issue.
59 |
60 | ## 🧾License
61 | This project is licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full license text.
62 |
--------------------------------------------------------------------------------
/web/src/components/SortSelector.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { Dropdown, DropdownHeader, DropdownItem, DropdownDivider, Modal, ModalBody, ModalHeader, ModalFooter, Button, Popover } from 'flowbite-react'
3 | import { RiArrowUpDownLine } from "react-icons/ri";
4 | import { RiArrowDownLine } from "react-icons/ri";
5 | import { RiArrowUpLine } from "react-icons/ri";
6 | import { useTranslation } from 'react-i18next';
7 |
8 | function SortSelector({sort, setSort, order, setOrder}) {
9 | const { t } = useTranslation();
10 |
11 | const handleSort = (item) => {
12 | setSort(item);
13 | localStorage.setItem("last_sorted", JSON.stringify(item));
14 | }
15 |
16 | const handleOrder = () => {
17 | if (order === "asc") {
18 | setOrder("desc");
19 | localStorage.setItem("last_ordered", "desc");
20 | } else {
21 | setOrder("asc");
22 | localStorage.setItem("last_ordered", "asc");
23 | }
24 | }
25 |
26 | const dropdownLabel = (
27 | <>
28 |
29 | {t("sort.sort_by", {sortType: sort.name ? sort.name.toLowerCase() : t("sort.title").toLowerCase()})}
30 | >
31 | )
32 |
33 | const displayPopoverContent = (
34 |
35 |
36 |
{t("sort.order_by")}
37 |
38 |
39 | )
40 |
41 | return (
42 |
43 |
44 | handleSort({value: "title", name: t("sort.title")})}>{t("sort.title")}
45 | handleSort({value: "author", name: t("sort.author")})}>{t("sort.author")}
46 | handleSort({value: "progress", name: t("sort.progress")})}>{t("sort.progress")}
47 | handleSort({value: "rating", name: t("sort.rating")})}>{t("sort.rating")}
48 | handleSort({value: "created_on", name: t("sort.date_added")})}>{t("sort.date_added")}
49 |
50 |
51 | handleOrder()}>
52 | {order === "asc" ? (
53 |
54 | ): (
55 |
56 | )}
57 |
58 |
59 |
60 | )
61 | }
62 |
63 | export default SortSelector
--------------------------------------------------------------------------------
/docs/docs/07 - API/edit-note.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: edit-note
3 | title: "Edit note"
4 | description: "Edit note"
5 | sidebar_label: "Edit note"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJx1lG1vmzAQgP8Kuk+bRAOJVnXjW7sXrdM0VV2mSYui6gIXcAM2tY90GeK/T2cgNGv3zT7f+z2+Fmq0WBGTdZCsWlAaEqiRCwhBY0WQgMogBEsPjbKUQcK2oRBcWlCFkLTAh9praaacLHTdutcmx1cmO4hKajSTZjliXZcqRVZGR/fOaJFNvmprarKsyP1jlpFLrarFDBL4ZpiC8TUcM3Bslc6hCyG1hEzZXe/+1PYDMgVmG2jvQxSV0cEr49+xfA0hbI2tkCGBDJnOWFX0UpCHxjDd1ZjT8yA3mPsgXFDg9SYHxz6FsFdObVSp+PCkj2OA7mhhNveUMnQimsawxdKRl7jaaNd3bBHH/+tWgTqnLHBNSs5tm7I8zCSH85csfuidNo86IGuNnflcHKWN9ZmuWtgQWrKXDReQrNYycMZc+PGxHKzDfxx+zBT7jkMIFXFhsp6yVDDztCUQ7eeRqLioVVkHU8jvgkdf39PA4+3TOK0vP5cwgClt61+nxhfMtS9FsLudAP34G6u6pBPgximcogSLePHmLL44W7xbzs+T83myeDuLL+a/4JSG+HS0T5hRemvGQJh6shtbDsm5JIpyxUWzmaWmiirz54+Zx3EcbYzZlSb3zJz29fLmOtgaG2AqU1U6D66M2X0V3RBYsZQFoyi4vLmGEPZkXW89n8WzWJzWxnGF/rMMf/7pwE5CHjll+s1RXaLS4sFX0Q6jXMF+LuvDsxBCojIhojCO5a1tN+johy27TsQPDVmhah3CHq3CjeS8aiFTTs4j6c/SmGb16nb4FK+Dadqn6Q1C1AdpAJaN3CCEHR36BdetuxAKwoysj94/vO9jnC3FfDJ8tsPkBxyxvrlcvv8MIWyG5VeZTIwsPsoaxcc+cr9vPNRe1kKJOm88P9A7FVZxQH0o4Mj0cJBUX6ytbXuNpdmR7rpjqSx3qbbr/gKNCgZq
9 | sidebar_class_name: "patch api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Edit note
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/web/src/components/AddNoteModal.jsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react'
2 | import { Modal, ModalBody, ModalHeader, Label, Textarea, ToggleSwitch, ModalFooter, Button } from 'flowbite-react'
3 | import BooksService from '../services/books.service';
4 | import useToast from '../toast/useToast';
5 | import { useTranslation } from 'react-i18next';
6 |
7 | function AddNoteModal({bookID, open, setOpen, onSave}) {
8 | const [publicSwitch, setPublicSwitch] = useState(false);
9 | const [noteContent, setNoteContent] = useState();
10 |
11 | const toast = useToast(4000);
12 | const { t } = useTranslation();
13 |
14 | const save = () => {
15 | let data = {}
16 | data.content = noteContent;
17 |
18 | if(publicSwitch) {
19 | data.visibility = "public";
20 | }
21 |
22 | BooksService.addNote(bookID, data).then(
23 | response => {
24 | toast("success", response.data.message)
25 | setNoteContent();
26 | onSave();
27 | setOpen(false);
28 | setPublicSwitch(false);
29 | },
30 | error => {
31 | const resMessage =
32 | (error.response &&
33 | error.response.data &&
34 | error.response.data.message) ||
35 | error.message ||
36 | error.toString();
37 | toast("error", resMessage);
38 | }
39 | )
40 | }
41 |
42 | return (
43 | setOpen(false)}>
44 | {t("notes.add_note")}
45 |
46 |
47 |
48 |
49 | {t("notes.note")}
50 |
51 |
53 |
54 |
55 |
56 |
57 |
58 | setOpen(false)}>{t("forms.cancel")}
59 | save()}>{t("forms.save")}
60 |
61 |
62 |
63 | )
64 | }
65 |
66 | export default AddNoteModal
--------------------------------------------------------------------------------
/docs/src/components/HomepageFeatures/index.js:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import Heading from '@theme/Heading';
3 | import styles from './styles.module.css';
4 | import Link from '@docusaurus/Link';
5 |
6 | const FeatureList = [
7 | {
8 | title: 'Build your virtual library',
9 | Img: "/img/feature_section_01.png",
10 | pos: "left",
11 | description: (
12 | <>
13 | Add your books to lists depending on if you have read them, are
14 | currently reading or want to read .
15 | >
16 | ),
17 | },
18 | {
19 | title: 'Share Your Reading Journey',
20 | Img: "/img/feature_section_02.png",
21 | pos: "right",
22 | description: (
23 | <>
24 | Let your friends see what you are currently reading and what you have finished. It is a simple way to showcase your taste in books and keep others in the loop on your reading journey.
25 | >
26 | ),
27 | },
28 | {
29 | title: 'Find Your Favorite Books in Seconds',
30 | Img: "/img/feature_section_03.png",
31 | pos: "left",
32 | description: (
33 | <>
34 | Powered by OpenLibrary . You can search through millions of titles — with new books added every day. Discover old favorites, explore new reads, and build your library effortlessly.
35 | >
36 | ),
37 | },
38 | ];
39 |
40 | function Feature({Img, title, description, pos}) {
41 | return (
42 |
43 | {pos === "right" &&
44 | <>
45 |
46 |
47 |
48 |
{title}
49 |
{description}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | >
59 | }
60 | {pos === "left" &&
61 | <>
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
{title}
70 |
{description}
71 |
72 |
73 | >
74 | }
75 |
76 | );
77 | }
78 |
79 | export default function HomepageFeatures() {
80 | return (
81 |
82 |
83 | {FeatureList.map((props, idx) => (
84 |
85 | ))}
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/web/public/fallback-cover-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | BOOK COVER NOT AVAILABLE
63 |
--------------------------------------------------------------------------------
/docs/docs/07 - API/add-note-to-book.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: add-note-to-book
3 | title: "Add note to book"
4 | description: "Add note to book"
5 | sidebar_label: "Add note to book"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJx1VNtu2zAM/RWDTyngxE6wopvf2nUDMgxt0WYbsCAYZJux1diWK9HpUsP/PlBybk32Jom3w8MjtlALLUok1AaieQspmkTLmqSqIILpraeWXqzUyhvc3c+86dPN3QX4INlYC8rBh0qUCBHIFHzQ+NJIjSlEpBv0wSQ5lgKiFmhTW6+KMEMNXbdw3mjoRqUbdklURVgRH0VdFzIRjCJ4NgylPchVa1WjJonmXdgx+M/OwB1Qjl6lCMHfAjGkZZVB50OiURCmf1yV4xS3gpDjOdazjlJV3kBZuyiYiqXSpSCIIBWEQ5Ll2SJraWQsC0mb0yI/d7ZDqN4gl2mKlae0VzdxIZOL08TdIeXzHRWLnaOKnzEh6I49eTj2wdSqMo7GSRieQrvb9Y3pyJYzmDTatjFvIUahUV83lEM0X/BISWSsI7hRamUYx3G+6zR1zZGyqgIfSqRcpSwnZQh8p6oIgvU4YA8TtDLtAg4ysC//xGJwwA9BbG9ft0P59msGvQyZDmfd85gT1bYtFtnjXo5f/oqyLvBIXlvSjxUDk3DyYRheDSefZuPL6HIcTT6Owqvxbzge+oEWZLVU28wiscJtdNGjMVEQZJLyJh4lqgxK9famxmEYWjIKlWnO8I7Uh6m3VNoTSYLGyCrzmP3v7OsDSeI+YPvkXT9MGRpq46LHo3AUclLmvxT2E/Rf+sy0jirvfjXhXwrqQsiKE9lm2n6Qc1iPwQc7SvAhsmvCTXPhQ84zj+bQtrEw+EMXXcfPLw1qVtjCh7XQUsTcAi8naficQrQUhcETOPtZDR57sV94/9thZ7H3j6LaMEmiaPgGPqxw43Zct+h8yFGkqC0kZ+hXzXDG4fvAkzXGX2Qn+If7p5mlxq2/UqUco8UrL1Lx6gq7VWOFbt9aKESVNSJjX5eT9St6+ff4dzrvD4z0bGtt6zxmaoVV1+06Jb5zs133D2LXEZI=
9 | sidebar_class_name: "post api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Add note to book
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/web/src/components/InterfaceTab.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useTranslation } from 'react-i18next';
3 | import useToast from '../toast/useToast';
4 | import { ToggleSwitch } from "flowbite-react";
5 | import LanguageSwitcher from './LanguageSwitcher';
6 | import RegionSwitcher from './RegionSwitcher';
7 | import TimezoneSwitcher from './TimezoneSwitcher';
8 |
9 | function InterfaceTab() {
10 | const [use24Hour, setUse24Hour] = useState(localStorage.getItem("time_format_24h") === "true" ? true : false);
11 | const toast = useToast(4000);
12 | const { t } = useTranslation();
13 |
14 | const setTimeFormat = (format) => {
15 | setUse24Hour(format);
16 | localStorage.setItem("time_format_24h", format);
17 | toast("success", t("language.time_format_updated_toast"));
18 | }
19 |
20 | return (
21 |
22 |
23 |
24 |
{t("language.language")}
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
{t("language.region")}
33 |
{t("language.region_info")}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
{t("language.timezone")}
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
{t("language.time_format")}
53 |
54 |
55 |
56 |
{t("language.12hour")}
57 |
setTimeFormat(!use24Hour)}/>
58 | {t("language.24hour")}
59 |
60 |
61 |
62 |
63 | )
64 | }
65 |
66 | export default InterfaceTab
--------------------------------------------------------------------------------
/docs/docs/07 - API/add-book-to-list.api.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: add-book-to-list
3 | title: "Add book to list"
4 | description: "Add book to list"
5 | sidebar_label: "Add book to list"
6 | hide_title: true
7 | hide_table_of_contents: true
8 | api: eJyFVE1v2zAM/SsCz17i7Ohbuw+gw7AVbYYdgqCQbcZWY0uuRKdNDf/3gbJc10mw3iSKfHokH9mBxacWHV2b/AhJB5nRhJr4KJumUpkkZfTy0RnNNpeVWEs+NdY0aEmh874tlcbyKUeXWdVwFCRw5e3C7ASVKFJj9hABHRuEBBxZpQvoI8haa1HTQyMLHDB2sq0Ikjg6wfsyeAr2FCkqXQiLMp9AlSYs0DLqLPKU2Nfp9hE75dILADf317+EbusU7aUgZqV08eBIUutmScHaiBRH3nPUuyFMhLAIULc1JJt5TKhCdRThG4h8JGwvMCFFFZ7zX7P5o9TJkKx8W9z/+7Jmx1AOBh1CzrviK/PUKos5ZzVwCyWeyJv0ETOCfu5OtkVvcI3RbmD0OY7PU7s2Zi9knmMuyIhKOVr4nx1mrVV0hGTTQYrSomV9QrLZ9vy5LByT4nDHbE6knOe+TiMmRFAjlSaHBBrj741kNFgeVsvUg0x/3vPgDJzf/zzevhtbS9bGj79rjvLekITXqZAlUeNz4YG8m0b324usm6HN4yiObTwdr7PevfkNQp/upxqeizDoanKfiSXmwdE7M+4Umfmd0toqZOGS5bJQVLbpIjP1sjavr2YVx7GvXGWK8xGGq9sbsTNWyCxD53hMuFU/2XeiM5rE1e0NRHBA64bo1SJexAzKzaqlH2ktfZUvtPZkeYTyE77Qsqmk0gzkk+lC1zdwWEEE6SiekiWRbKDrUunwj636ns1PLVoW4DaCg7RKpkx6s+0jKFHmaL0293jkXTes4k9r/pvdq5Y5nK1l1u6bEm9/3689jWGd1ybnGCufuZ/yGRKACIzPy4vR2zqopC5aLw8YMFljMkg0JP+mxXBgpuFJ6uM7hl03eKzNHnXfQxQSIr5Dv+37/h+FMzWu
9 | sidebar_class_name: "post api-method"
10 | info_path: docs/07 - API/booklogr-api
11 | custom_edit_url: null
12 | ---
13 |
14 | import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
15 | import ParamsDetails from "@theme/ParamsDetails";
16 | import RequestSchema from "@theme/RequestSchema";
17 | import StatusCodes from "@theme/StatusCodes";
18 | import OperationTabs from "@theme/OperationTabs";
19 | import TabItem from "@theme/TabItem";
20 | import Heading from "@theme/Heading";
21 |
22 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 | Add book to list
40 |
41 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/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 | import { useTranslation, Trans } from 'react-i18next';
6 |
7 | const customThemeSelect = {
8 | base: "flex",
9 | field: {
10 | base: "relative w-full",
11 | icon: {
12 | base: "pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3",
13 | svg: "h-5 w-5 text-gray-500 dark:text-gray-400",
14 | },
15 | select: {
16 | base: "block w-full appearance-none border bg-arrow-down-icon bg-[length:0.75em_0.75em] bg-[position:right_12px_center] bg-no-repeat pr-10 focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
17 | withIcon: {
18 | on: "pl-10",
19 | off: "",
20 | }
21 | },
22 | }
23 | }
24 |
25 | function RequestData(props) {
26 | const [dataFormat, setDataFormat] = useState("csv")
27 | const toast = useToast(4000);
28 | const { t } = useTranslation();
29 |
30 | const requestData = () => {
31 | let taskType;
32 | if (dataFormat == "csv") {
33 | taskType = "csv_export"
34 | }else if (dataFormat == "json") {
35 | taskType = "json_export"
36 | }else if (dataFormat == "html") {
37 | taskType = "html_export"
38 | }
39 | TasksService.create(taskType, {}).then(
40 | response => {
41 | toast("success", response.data.message);
42 | props.onRequest();
43 | },
44 | error => {
45 | const resMessage =
46 | (error.response &&
47 | error.response.data &&
48 | error.response.data.message) ||
49 | error.message ||
50 | error.toString();
51 | toast("error", resMessage);
52 | }
53 | )
54 | }
55 |
56 | return (
57 |
58 |
59 |
60 |
{t("settings.data.request_data.title")}
61 |
{t("settings.data.request_data.description")}
62 |
63 |
64 |
65 | {t("settings.data.request_data.format")}
66 |
67 |
setDataFormat(e.target.value)}>
68 | CSV
69 | JSON
70 | HTML
71 |
72 |
73 |
requestData()}>{t("settings.data.request_data.title")}
74 |
75 |
76 | )
77 | }
78 |
79 | export default RequestData
--------------------------------------------------------------------------------
/web/src/components/Library/EditBookModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 | import { Modal, ModalHeader, ModalBody, ModalFooter, TextInput, Button, Popover, Label} from 'flowbite-react';
3 | import useToast from '../../toast/useToast';
4 | import BooksService from '../../services/books.service';
5 | import { RiQuestionLine } from "react-icons/ri";
6 | import { useTranslation, Trans } from 'react-i18next';
7 |
8 | function EditBookModal(props) {
9 | const [saveButtonDisabled, setSaveButtonDisabled] = useState(false);
10 | const [totalPages, setTotalPages] = useState(props.totalPages);
11 | const toast = useToast(4000);
12 | const { t } = useTranslation();
13 |
14 | const handleEditBook = () => {
15 | BooksService.edit(props.id, {total_pages: parseInt(totalPages)}).then(
16 | response => {
17 | toast("success", response.data.message);
18 | props.close(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 | props.close(false);
29 | toast("error", resMessage);
30 | }
31 | )
32 | }
33 |
34 | const displayPopoverContent = (
35 |
36 |
37 |
{t("help.title")}
38 |
39 |
40 |
{t("help.number_of_pages_information")}
41 |
42 |
43 | )
44 |
45 | return (
46 | <>
47 | props.close(false)}>
48 | {t("actions.edit_book")}
49 |
50 |
51 |
52 |
{t("book.total_pages")}
53 |
54 |
55 |
56 |
57 |
setTotalPages(e.target.value)} />
58 |
59 |
60 |
61 | handleEditBook()}>{t("forms.save")}
62 | props.close(false)}>
63 | {t("forms.close")}
64 |
65 |
66 |
67 | >
68 | )
69 | }
70 |
71 | export default EditBookModal
--------------------------------------------------------------------------------
/web/src/components/AddQuoteModal.jsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react'
2 | import { Modal, ModalBody, ModalHeader, Label, Textarea, ToggleSwitch, ModalFooter, Button, TextInput } from 'flowbite-react'
3 | import BooksService from '../services/books.service';
4 | import useToast from '../toast/useToast';
5 | import { useTranslation } from 'react-i18next';
6 |
7 | function AddQuoteModal({bookID, open, setOpen, onSave}) {
8 | const [publicSwitch, setPublicSwitch] = useState(false);
9 | const [quoteContent, setQuoteContent] = useState();
10 | const [quotePage, setQuotePage] = useState(0);
11 |
12 | const toast = useToast(4000);
13 | const { t } = useTranslation();
14 |
15 | const save = () => {
16 | let data = {}
17 | data.content = quoteContent;
18 | data.quote_page = quotePage;
19 |
20 | if(publicSwitch) {
21 | data.visibility = "public";
22 | }
23 | BooksService.addNote(bookID, data).then(
24 | response => {
25 | toast("success", response.data.message)
26 | setQuoteContent();
27 | onSave();
28 | setOpen(false);
29 | setPublicSwitch(false);
30 | setQuotePage(0);
31 | },
32 | error => {
33 | const resMessage =
34 | (error.response &&
35 | error.response.data &&
36 | error.response.data.message) ||
37 | error.message ||
38 | error.toString();
39 | toast("error", resMessage);
40 | }
41 | )
42 | }
43 |
44 | return (
45 | setOpen(false)}>
46 | {t("notes.add_quote")}
47 |
48 |
49 |
50 |
51 | {t("notes.quote")}
52 |
53 |
55 |
56 |
57 |
58 | {t("notes.page_number")}
59 |
60 |
setQuotePage(e.target.value)} />
61 |
62 |
63 |
64 |
65 |
66 |
67 | setOpen(false)}>{t("forms.cancel")}
68 | save()}>{t("forms.save")}
69 |
70 |
71 |
72 | )
73 | }
74 |
75 | export default AddQuoteModal
--------------------------------------------------------------------------------
/web/src/components/Data/FileImport.jsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import { FileInput, Button, Label, HelperText, Checkbox, Card, Dropdown, DropdownItem, Select } from "flowbite-react";
3 | import FilesService from '../../services/files.service';
4 | import useToast from '../../toast/useToast';
5 | import { useTranslation, Trans } from 'react-i18next';
6 |
7 | function FileImport() {
8 | const [file, setFile] = useState(null);
9 | const [uploading, setUploading] = useState(false);
10 | const [allowDuplicates, setAllowDuplicates] = useState(false);
11 | const [platform, setPlatform] = useState("csv");
12 | const toast = useToast(4000);
13 | const { t } = useTranslation();
14 |
15 | const handleUpload = () => {
16 | const formData = new FormData();
17 | formData.append("file", file);
18 | formData.append("type", platform)
19 |
20 | if (allowDuplicates) {
21 | formData.append("allow_duplicates", "true")
22 | }
23 |
24 | setUploading(true);
25 | FilesService.upload(formData).then(
26 | response => {
27 | toast("success", response.data.message)
28 | setUploading(false);
29 | },
30 | error => {
31 | const resMessage =
32 | (error.response &&
33 | error.response.data &&
34 | error.response.data.message) ||
35 | error.message ||
36 | error.toString();
37 | toast("error", resMessage);
38 | setUploading(false);
39 | }
40 | )
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 |
{t("settings.data.import_books.title")}
48 |
49 |
50 |
51 |
52 | {t("settings.data.import_books.platform")}
53 | setPlatform(e.target.value)}>
54 | BookLogr CSV
55 | Goodreads
56 |
57 |
58 |
59 |
60 |
{t("settings.data.import_books.file")}
61 |
setFile(e.target.files[0])}/>
62 |
63 | setAllowDuplicates(!allowDuplicates)} />
64 | {t("settings.data.import_books.add_duplicate_books")}
65 |
66 |
67 |
68 |
{uploading ? t("forms.uploading"): t("forms.upload")}
69 |
70 |
71 | )
72 | }
73 |
74 | export default FileImport
--------------------------------------------------------------------------------
/web/src/components/UpdateReadingStatusView.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef }from 'react'
2 | import { Tabs, TabItem, Label, TextInput } from 'flowbite-react'
3 | import { RiPercentLine } from "react-icons/ri";
4 | import { RiBookOpenLine } from "react-icons/ri";
5 | import { useTranslation, Trans } from 'react-i18next';
6 |
7 | function UpdateReadingStatusView({title, currentPage, setCurrentPage, totalPages, onNoProgressError = ()=> {}, onProgressLesserError = ()=> {}, onProgressGreaterError = ()=> {}}) {
8 | const [percentage, setPercentage] = useState(0);
9 | const [progressErrorText, setPasswordErrorText] = useState();
10 | const tabsRef = useRef(null);
11 | const [activeTab, setActiveTab] = useState(0);
12 | const { t } = useTranslation();
13 |
14 | useEffect(() => {
15 | setPercentage(((currentPage / totalPages) * 100).toFixed(0));
16 | if (currentPage > totalPages) {
17 | setPasswordErrorText(t("book.update_reading.error.greater_than"));
18 | onProgressGreaterError();
19 | } else if (currentPage < 0) {
20 | setPasswordErrorText(t("book.update_reading.error.less_than"));
21 | onProgressLesserError();
22 | } else {
23 | setPasswordErrorText();
24 | onNoProgressError();
25 | }
26 | }, [currentPage])
27 |
28 | useEffect(() => {
29 | if (activeTab == 0) {
30 | localStorage.setItem("use_percentage_book_read", false)
31 | }else if (activeTab == 1) {
32 | localStorage.setItem("use_percentage_book_read", true)
33 | }
34 | }, [activeTab])
35 |
36 | return (
37 |
38 |
39 | {title}
43 | )
44 | }}
45 | />
46 |
47 |
48 |
setActiveTab(tab)}>
49 |
50 | {t("book.update_reading.current_page")}
51 | setCurrentPage(e.target.value)} color={progressErrorText ? 'failure' : 'gray'}/>
52 | {t("book.update_reading.progress", {percentage: percentage})}
53 |
54 |
55 | {t("book.update_reading.percentage_complete")}
56 | setCurrentPage(Math.round((e.target.value / 100) * totalPages))} color={progressErrorText ? 'failure' : 'gray'}/>
57 | {t("book.update_reading.current_page_of", {current_page: currentPage, total_pages: totalPages})}
58 |
59 |
60 |
61 | {progressErrorText}
62 |
63 |
64 |
65 | )
66 | }
67 |
68 | export default UpdateReadingStatusView
--------------------------------------------------------------------------------
/web/src/components/EditionSelector.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 | import OpenlibraryService from '../services/openlibrary.service';
3 | import useToast from '../toast/useToast';
4 | import { Button, ButtonGroup, Dropdown, DropdownHeader, DropdownItem, Modal, ModalBody, ModalHeader } from 'flowbite-react';
5 | import { RiBook2Line } from "react-icons/ri";
6 | import { Link } from 'react-router-dom';
7 | import EditionItem from './EditionItem';
8 | import { RiExternalLinkLine } from "react-icons/ri";
9 | import { useTranslation, Trans } from 'react-i18next';
10 |
11 | function EditionSelector({work_id, selected_isbn}) {
12 | const [editionList, setEditionList] = useState();
13 | const [openModal, setOpenModal] = useState(false);
14 | const toast = useToast(4000);
15 | const { t } = useTranslation();
16 |
17 | useEffect(() => {
18 | if (work_id) {
19 | OpenlibraryService.getEditions(work_id, 100).then(
20 | response => {
21 | setEditionList(response.data.entries)
22 | console.log(response.data.entries)
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", "OpenLibrary: " + resMessage);
32 | }
33 | )
34 | }
35 | }, [work_id])
36 |
37 | return (
38 | <>
39 | {t("editions.change")}} color={"light"}>
40 |
41 |
42 |
{t("editions.title")} ({editionList?.length || 0})
43 |
setOpenModal(true)}>
44 |
45 |
46 |
47 |
48 |
49 | {editionList?.map(function(data) {
50 | return (
51 | data.isbn_13?.[0] && (
52 |
53 |
54 |
55 | )
56 |
57 | )
58 | })}
59 |
60 |
61 |
62 | setOpenModal(false)}>
63 | {t("editions.title")} ({editionList?.length || 0})
64 |
65 | {editionList?.map(function(data) {
66 | return (
67 | data.isbn_13?.[0] && (
68 |
69 |
70 |
71 |
72 |
73 | )
74 | )
75 | })}
76 |
77 |
78 | >
79 | )
80 | }
81 |
82 | export default EditionSelector
--------------------------------------------------------------------------------
/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 ToastContainer from "./toast/Container";
5 | import NavigationMenu from "./components/Navbar"
6 | import Login from "./pages/Login";
7 | import Profile from "./pages/Profile";
8 | import Footer from "./components/Footer";
9 | import Register from "./pages/Register";
10 | import AuthService from "./services/auth.service";
11 | import SidebarNav from "./components/SidebarNav";
12 | import Verify from "./pages/Verify";
13 | import Settings from "./pages/Settings";
14 | import globalRouter from "./GlobalRouter";
15 | import { AnimatePresence } from "framer-motion";
16 | import { useEffect } from "react";
17 | import { useThemeMode } from "flowbite-react";
18 | import i18n from "./i18n";
19 |
20 | function PrivateRoute({ children }) {
21 | const auth = AuthService.getCurrentUser()
22 | return auth ? children : ;
23 | }
24 |
25 | function App() {
26 | const navigate = useNavigate();
27 | globalRouter.navigate = navigate;
28 | const mode = useThemeMode();
29 |
30 | let location = useLocation();
31 |
32 | // Set the theme based on localStorage if it exists.
33 | useEffect(() => {
34 | if(localStorage.getItem("flowbite-theme-mode") === "dark") {
35 | mode.setMode("dark");
36 | } else if(localStorage.getItem("flowbite-theme-mode") === "light") {
37 | mode.setMode("light");
38 | }
39 |
40 | if(!localStorage.getItem("flowbite-theme-mode")) {
41 | localStorage.setItem("flowbite-theme-mode", "light");
42 | }
43 | }, []);
44 |
45 | // Change dir of the document when the language updates
46 | useEffect(() => {
47 | const setDir = () => {
48 | document.documentElement.setAttribute(
49 | "dir",
50 | i18n.language === "ar" ? "rtl" : "ltr"
51 | );
52 | };
53 |
54 | setDir(); // Set initial direction
55 |
56 | i18n.on("languageChanged", setDir); // Listen for language changes
57 |
58 | return () => {
59 | i18n.off("languageChanged", setDir); // Clean up the listener
60 | };
61 | }, []); // Empty dependency array ensures this effect runs only once on mount
62 |
63 |
64 | return (
65 |
66 |
67 | {AuthService.getCurrentUser() &&
68 |
69 | }
70 |
71 |
72 | {!AuthService.getCurrentUser() &&
73 | location.pathname != "/library" &&
74 |
75 |
76 | }
77 |
78 |
79 |
80 | } />
81 |
82 | } />
83 | } />
84 | } />
85 | } />
86 | } />
87 |
88 | } />
89 | } />
90 | } />
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | )
101 | }
102 |
103 | export default App
104 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | import { useTranslation, Trans } from 'react-i18next';
8 |
9 | function Verify() {
10 | const [code, setCode] = useState();
11 | const [email, setEmail] = useState();
12 |
13 | let navigate = useNavigate();
14 | let location = useLocation();
15 | const toast = useToast(4000);
16 | const { t } = useTranslation();
17 |
18 | useEffect(() => {
19 | setEmail(location.state.email);
20 | }, [location.state])
21 |
22 |
23 | const handleVerify = (e) => {
24 | e.preventDefault();
25 | AuthService.verify(email, code).then(
26 | response => {
27 | toast("success", response.data.message + ". Please login!")
28 | navigate("/login")
29 | },
30 | error => {
31 | const resMessage =
32 | (error.response &&
33 | error.response.data &&
34 | error.response.data.message) ||
35 | error.message ||
36 | error.toString();
37 | toast("error", resMessage);
38 | }
39 | )
40 | }
41 |
42 | const maskEmail = (address) => {
43 | const regex = /(^.|@[^@](?=[^@]*$)|\.[^.]+$)|./g;
44 | return address.replace(regex, (x, y) => y || '*')
45 | };
46 |
47 | return (
48 |
49 |
50 |
51 |
52 | {t("verification.title")}
53 | {email &&
54 |
55 | {maskEmail(email)}
59 | )
60 | }}
61 | />
62 |
63 | }
64 |
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | export default Verify
--------------------------------------------------------------------------------
/web/src/components/EditNoteModal.jsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import { Modal, ModalBody, ModalHeader, Label, Textarea, ToggleSwitch, ModalFooter, Button, TextInput } from 'flowbite-react'
3 | import NotesService from '../services/notes.service';
4 | import useToast from '../toast/useToast';
5 | import { useTranslation } from 'react-i18next';
6 |
7 | function EditNoteModal({noteID, content, page, open, visibility, setOpen, onSave}) {
8 | const [publicSwitch, setPublicSwitch] = useState(visibility == "hidden" ? false : visibility == "public" ? true : false);
9 | const [noteContent, setNoteContent] = useState(content);
10 | const [quotePage, setQuotePage] = useState(page);
11 |
12 | const toast = useToast(4000);
13 | const { t } = useTranslation();
14 |
15 | let noteOrQuote = page ? t("notes.quote") : t("notes.note");
16 |
17 | const save = () => {
18 | let data = {};
19 |
20 | if(publicSwitch != (visibility == "hidden" ? false : visibility == "public" ? true : false)) {
21 | if (publicSwitch === true) {
22 | data.visibility = "public";
23 | } else if(publicSwitch === false) {
24 | data.visibility = "hidden";
25 | }
26 | }
27 |
28 | if (noteContent != content) {
29 | data.content = noteContent;
30 | }
31 |
32 | if (page != quotePage) {
33 | data.quote_page = quotePage;
34 | }
35 |
36 | NotesService.edit(noteID, data).then(
37 | response => {
38 | toast("success", response.data.message)
39 | onSave();
40 | setOpen(false);
41 | },
42 | error => {
43 | const resMessage =
44 | (error.response &&
45 | error.response.data &&
46 | error.response.data.message) ||
47 | error.message ||
48 | error.toString();
49 | toast("error", resMessage);
50 | }
51 | )
52 | }
53 |
54 | return (
55 | setOpen(false)}>
56 | {t("notes.edit")} {noteOrQuote.toLowerCase()}
57 |
58 |
59 |
60 |
61 | {noteOrQuote}
62 |
63 |
65 | {page ? (
66 |
67 |
68 | {t("notes.page_number")}
69 |
70 |
setQuotePage(e.target.value)} />
71 |
72 | ):(
73 | <>>
74 | )}
75 |
76 |
77 |
78 |
79 |
80 | setOpen(false)}>{t("forms.cancel")}
81 | save()}>{t("forms.save")}
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | export default EditNoteModal
--------------------------------------------------------------------------------
/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, Popover } from 'flowbite-react';
5 | import AuthService from '../services/auth.service';
6 | import { Spinner } from 'flowbite-react';
7 | import { useTranslation, Trans } from 'react-i18next';
8 |
9 | function GoogleLoginButton(props) {
10 | let navigate = useNavigate();
11 | const [loading, setLoading] = useState(false);
12 | const { t } = useTranslation();
13 |
14 | const handleLoginGoogle = useGoogleLogin(
15 | {
16 | flow: 'auth-code',
17 | ux_mode: 'popup',
18 | onSuccess: (codeResponse) => {
19 | AuthService.loginGoogle(codeResponse.code).then(
20 | response => {
21 | setLoading(false);
22 | navigate("/")
23 | },
24 | error => {
25 | setLoading(false);
26 | }
27 | )
28 | },
29 | onNonOAuthError: () => {
30 | setLoading(false);
31 | }
32 | });
33 |
34 | const googleButton = (
35 | (handleLoginGoogle(), setLoading(true))}>
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {t("forms.google_sign_in")}
44 | {props.error &&
45 |
46 | ⚠️
47 | }
48 |
49 | );
50 |
51 | const displayPopoverContent = (
52 |
53 |
54 |
{t("help.google_client_id_error.title")}
55 |
56 |
57 |
58 |
59 |
63 | )
64 | }}
65 | />
66 |
67 |
68 |
69 | )
70 |
71 | return (
72 | <>
73 | {loading ? (
74 |
75 |
76 |
77 | ): (
78 | props.error ? (
79 |
80 |
81 | {googleButton}
82 |
83 |
84 | ):(
85 | googleButton
86 | )
87 |
88 | )}
89 |
90 | >
91 |
92 | )
93 | }
94 |
95 | export default GoogleLoginButton
--------------------------------------------------------------------------------
/docs/docs/06 - Developer/Translation.md:
--------------------------------------------------------------------------------
1 | # Translating BookLogr Into Your Language
2 |
3 | BookLogr supports localization through simple translation files. Each language has its own file stored in the project directory. This guide walks you through the steps to translate BookLogr into your preferred language.
4 |
5 | ---
6 |
7 | ## How to translate
8 | Language files are located in `web/src/locales`
9 | Each language has it's own subfolder named after their respective language code (e.g. `en` for English, `sv` for Swedish). Inside, you’ll find a `.json` file containing the translation keys and values.
10 |
11 | 1. **Locate the Language File**
12 | - Navigate to `web/src/locales//.json`
13 | - For example, English is stored at:
14 | `web/src/locales/en/en.json`
15 | 2. **Edit the File**
16 | - Each entry is structured as a key-value pair.
17 | - **Translate only the value**, not the key.
18 |
19 | #### Example: Original English
20 | ```json
21 | {
22 | "reading_status": {
23 | "read": "Read",
24 | "currently_reading": "Currently reading",
25 | "to_be_read": "To be read"
26 | }
27 | }
28 | ```
29 |
30 | #### Example: Translated to Swedish
31 | ```json
32 | {
33 | "reading_status": {
34 | "read": "Läst",
35 | "currently_reading": "Läser just nu",
36 | "to_be_read": "Att läsa"
37 | }
38 | }
39 | ```
40 | :::warning
41 | Do **not** change the keys. These are used internally by the appplication and should not be modified or translated.
42 | :::
43 |
44 | To finish your translation contribution, the last thing you should do is commit your changes and open a pull request on the [projects GitHub page](https://github.com/mozzo1000/booklogr) so they can be merged into the project. That way, your work becomes part of the app’s official language support.
45 |
46 | ---
47 |
48 | # Adding a new language
49 |
50 | This guide walks developers through the process of adding a new language to the BookLogr project.
51 |
52 | :::tip
53 | If you are a translator and would like to contribute a new language to the project. Reach by opening an [issue](https://github.com/mozzo1000/booklogr) and requesting a new language to be added.
54 | :::
55 |
56 | :::warning
57 | Always use `en.json` as the base translation file when adding a new language. This ensures consistency in the translation keys across all languages.
58 | :::
59 | ---
60 |
61 | ## 1. Create the language file
62 | Add a new language folder in `web/src/locales` using the language code (e.g. `fr` for French) and copy the English translation as the base:
63 |
64 | ```bash
65 | mkdir -p web/src/locales/fr
66 | cp web/src/locales/en/en.json web/src/locales/fr/fr.json
67 | ```
68 |
69 | ## 2. Update `i18n.jsx`
70 | 1. Open the file `web/src/i18n.jsx` and import the new language translation file.
71 | 2. Add it to the `resources` object so it is included in the `react-i18next` configuration.
72 |
73 | For example, if you're adding French (`fr`):
74 |
75 | ```jsx
76 | import en from "./locales/en/en.json";
77 | import sv from "./locales/sv/sv.json";
78 | import fr from "./locales/fr/fr.json";
79 |
80 | const resources = {
81 | en: { translation: en },
82 | sv: { translation: sv },
83 | fr: { translation: fr }
84 | };
85 | ```
86 |
87 | ## 3. Update the Language Switcher Component
88 | To make the new language selectable in the web interface,
89 | open the file `web/src/components/LanguageSwitcher.jsx` and add a new object to the `languages` array.
90 |
91 | For example, to add French (`fr`):
92 | ```jsx
93 | const languages = [
94 | { code: "en", label: "English", flag: "🇬🇧" },
95 | { code: "sv", label: "Svenska", flag: "🇸🇪" },
96 | { code: "fr", label: "Français", flag: "🇫🇷" }
97 | ];
98 | ```
99 | - `code`: Must match the language code used in `i18n.jsx`.
100 | - `label`: Display name shown in the dropdown menu.
101 | - `flag`: Emoji for the country's flag.
102 |
103 | 🎉 You can now edit the new language file in `web/src/locales`.
--------------------------------------------------------------------------------