Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
BACKEND_PORT ?= 8000
FRONTEND_PORT ?= 5173
PYTHON ?= python3
FRONTEND_DIR := $(CURDIR)/frontend

.PHONY: help dev website backend frontend stop

# Install JS deps when missing or after package-lock.json changes.
$(FRONTEND_DIR)/node_modules/.bin/vite: $(FRONTEND_DIR)/package-lock.json
cd "$(FRONTEND_DIR)" && npm ci

help:
@echo "Targets:"
@echo " make dev – API (:$(BACKEND_PORT)) + Vite (:$(FRONTEND_PORT)); Ctrl+C stops both"
Expand All @@ -16,7 +21,7 @@ help:
@echo "Override Python: make PYTHON=.venv/bin/python dev"

# Run both servers in one shell so INT/TERM kills all background jobs and frees ports.
dev website:
dev website: $(FRONTEND_DIR)/node_modules/.bin/vite
set -e; \
trap 'kill $$(jobs -p) 2>/dev/null || true; wait 2>/dev/null || true' INT TERM EXIT; \
cd "$(CURDIR)" && PYTHONPATH=. $(PYTHON) -m uvicorn app.main:app --reload --host 127.0.0.1 --port $(BACKEND_PORT) & \
Expand All @@ -28,8 +33,8 @@ dev website:
backend:
cd "$(CURDIR)" && PYTHONPATH=. $(PYTHON) -m uvicorn app.main:app --reload --host 127.0.0.1 --port $(BACKEND_PORT)

frontend:
cd "$(CURDIR)/frontend" && npm run dev
frontend: $(FRONTEND_DIR)/node_modules/.bin/vite
cd "$(FRONTEND_DIR)" && npm run dev

stop:
@for p in $(BACKEND_PORT) $(FRONTEND_PORT); do \
Expand Down
20 changes: 19 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
from pathlib import Path
from typing import AsyncIterator

from fastapi import FastAPI
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware

from app.deps import pipeline_locale
from app.locale import AppLocale
from app.version import __version__
from app.routers import library, recommend
from app.routers import books as books_router
from app.routers import reading_lists as reading_lists_router
from app.routers import settings as settings_router
from app.services.cache import init_cache
from app.services.demo_seed import clear_demo_books
from app.services.library_db import init_books_table, init_reading_lists_table
from app.services.settings_db import init_settings_table, seed_from_env

Expand Down Expand Up @@ -56,6 +59,21 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
app.include_router(reading_lists_router.router)


@app.post("/api/demo/clear-library")
async def api_clear_demo_library(
locale: AppLocale = Depends(pipeline_locale),
) -> dict[str, int]:
"""Remove seeded demo books (only when demo mode is active).

Defined on the app (not under ``/api/books/{id}``) so the path stays unambiguous
for reverse proxies and older clients.
"""
try:
return await clear_demo_books(locale)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc


@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
8 changes: 7 additions & 1 deletion app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@

from app.deps import pipeline_locale
from app.locale import AppLocale
from app.services.demo_seed import ensure_demo_library_seeded
from app.services.demo_seed import (
ensure_demo_library_seeded,
mark_library_emptied_by_user,
)
from app.services.library_db import (
BookIdentityConflictError,
delete_book,
get_all_books,
get_book,
import_books,
list_books,
Expand Down Expand Up @@ -174,4 +178,6 @@ async def api_update_book(book_id: str, body: BookUpdate) -> dict[str, Any]:
async def api_delete_book(book_id: str) -> dict[str, str]:
if not delete_book(book_id):
raise HTTPException(404, "Book not found")
if not get_all_books():
mark_library_emptied_by_user()
return {"status": "deleted"}
49 changes: 45 additions & 4 deletions app/services/demo_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@

from app.locale import AppLocale
from app.models import Book, EnrichedBook, ReaderProfile
from app.services.cache import CacheNamespace, make_key, set_cache
from app.services.library_db import get_all_books, upsert_book
from app.services.cache import CacheNamespace, invalidate, make_key, set_cache
from app.services.library_db import (
book_identity_id,
delete_book,
get_all_books,
reading_list_remove,
upsert_book,
)
from app.services.profile import ProfileBuilder
from app.services.recommendation_scoring import apply_match_scores_to_recommendation_dicts
from app.services.settings_db import set_setting
from app.services.settings_db import get_setting, set_setting

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -500,12 +506,47 @@
}

SETTING_DEMO_LIBRARY = "demo_library"
# When true, an empty library stays empty (user removed all books); do not re-seed demo.
SETTING_DEMO_AUTO_SEED_SUPPRESSED = "demo_auto_seed_suppressed"


def mark_library_emptied_by_user() -> None:
"""Remember that the user cleared the library so demo content is not auto-restored."""
set_setting(SETTING_DEMO_AUTO_SEED_SUPPRESSED, "true")
set_setting(SETTING_DEMO_LIBRARY, "false")


async def clear_demo_books(locale: AppLocale) -> dict[str, int]:
"""Remove seeded demo titles for this locale, drop their cache rows, and update flags."""
if get_setting(SETTING_DEMO_LIBRARY) != "true":
raise ValueError("Library is not in demo mode")

demo_books, _, _, _ = _DEMO_BY_LOCALE[locale]
removed = 0
for raw in demo_books:
title = str(raw["title"])
author = str(raw["author"])
book_id = book_identity_id(title, author)
reading_list_remove("planned", book_id)
reading_list_remove("blacklist", book_id)
if delete_book(book_id):
removed += 1
await invalidate(CacheNamespace.ENRICHED_BOOKS, make_key(locale, title, author))

if not get_all_books():
mark_library_emptied_by_user()
else:
set_setting(SETTING_DEMO_LIBRARY, "false")

return {"removed": removed}


async def ensure_demo_library_seeded(locale: AppLocale) -> None:
"""If the library is empty, seed demo content for the requested UI locale."""
"""Seed demo once on first-ever empty library, not again after the user deleted all books."""
if get_all_books():
return
if get_setting(SETTING_DEMO_AUTO_SEED_SUPPRESSED) == "true":
return
await seed_demo_library(locale)


Expand Down
2 changes: 1 addition & 1 deletion app/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
See https://semver.org/
"""

__version__ = "0.1.0"
__version__ = "0.2.0"
21 changes: 17 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
services:
api:
build: .
# Dev: live code from the repo + auto-reload (no image rebuild after editing Python).
command:
- uvicorn
- app.main:app
- --host
- 0.0.0.0
- --port
- "8000"
- --reload
ports:
- "8000:8000"
# Optional file: Docker Compose v2.24+; clone works without copying .env first.
Expand All @@ -10,29 +19,33 @@ services:
volumes:
# Named volume: DB is NOT ./data on the host. Use `docker compose down -v` to reset.
- bookshelf_data:/app/data
- ./app:/app/app:ro
- ./config.py:/app/config.py:ro

frontend:
image: node:22-bookworm-slim
working_dir: /app
# Root only for chmod; Vite runs as uid 1000 (image user "node"). node_modules is chmodded
# so any host UID can share the bind mount (avoids EACCES on .vite-temp for non-1000 users).
ports:
- "5173:5173"
environment:
API_PROXY_TARGET: http://api:8000
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
command:
- sh
- -c
- |
set -e
mkdir -p /app/node_modules
if [ ! -x node_modules/.bin/vite ]; then
npm ci
setpriv --reuid=1000 --regid=1000 --init-groups -- sh -c 'umask 000; npm ci'
fi
exec npm run dev -- --host 0.0.0.0
chmod -R a+rwX /app/node_modules
exec setpriv --reuid=1000 --regid=1000 --init-groups -- sh -c 'umask 000; exec npm run dev -- --host 0.0.0.0'
depends_on:
- api

volumes:
bookshelf_data:
frontend_node_modules:
15 changes: 15 additions & 0 deletions frontend/src/api/books.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { withAppLocale } from "./pipelineHeaders";

const BASE = "/api/books";

/** Fired on `window` when library data should be refetched (e.g. after bulk demo clear). */
export const LIBRARY_UPDATED_EVENT = "mybookshelf:library-updated";

export function notifyLibraryUpdated(): void {
window.dispatchEvent(new Event(LIBRARY_UPDATED_EVENT));
}

async function request<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
Expand Down Expand Up @@ -77,6 +84,14 @@ export function deleteBook(id: string): Promise<{ status: string }> {
return request<{ status: string }>(`${BASE}/${id}`, { method: "DELETE" });
}

/** Demo clear uses a dedicated app route (see ``app.main.api_clear_demo_library``). */
export function clearDemoBooks(locale: Locale): Promise<{ removed: number }> {
return request<{ removed: number }>(
"/api/demo/clear-library",
withAppLocale(locale, { method: "POST" }),
);
}

export async function importBooks(files: FileList): Promise<SyncResult> {
const form = new FormData();
for (const file of files) {
Expand Down
35 changes: 34 additions & 1 deletion frontend/src/components/DemoBanner.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
.demo-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
gap: 10px 12px;
padding: 10px 20px;
background: #fdf6e3;
border-bottom: 1px solid var(--color-border);
Expand All @@ -19,6 +20,38 @@
white-space: nowrap;
}

.demo-banner__clear {
flex-shrink: 0;
padding: 0.35rem 0.65rem;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.25;
color: var(--color-text);
background: rgba(255, 255, 255, 0.65);
border: 1px solid var(--color-border);
transition:
background-color 150ms ease,
border-color 150ms ease,
opacity 150ms ease;
}

.demo-banner__clear:disabled {
cursor: wait;
opacity: 0.8;
}

@media (hover: hover) {
.demo-banner__clear:hover:not(:disabled) {
background: var(--color-surface-hover);
}
}

.demo-banner__clear:focus-visible {
outline: 2px solid var(--color-accent, #2563eb);
outline-offset: 2px;
}

.demo-banner__dismiss {
flex-shrink: 0;
display: flex;
Expand Down
33 changes: 32 additions & 1 deletion frontend/src/components/DemoBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { clearDemoBooks, notifyLibraryUpdated } from "../api/books";
import { fetchSettingsStatus } from "../api/settings";
import { useI18n } from "../i18n/I18nContext";
import { useToast } from "./Toast";
import "./DemoBanner.css";

const LS_KEY = "demo_banner_dismissed";
Expand All @@ -22,11 +24,14 @@ const bannerMessages = {
} as const;

export function DemoBanner() {
const { locale } = useI18n();
const { locale, m } = useI18n();
const t = bannerMessages[locale];
const clearT = m.demoFab;
const { toast } = useToast();

const [visible, setVisible] = useState(false);
const [isDemo, setIsDemo] = useState(false);
const [clearingDemo, setClearingDemo] = useState(false);

useEffect(() => {
if (localStorage.getItem(LS_KEY) === "true") return;
Expand All @@ -50,11 +55,37 @@ export function DemoBanner() {
setVisible(false);
};

const handleClearDemo = async () => {
if (clearingDemo || !window.confirm(clearT.confirm)) return;
setClearingDemo(true);
try {
await clearDemoBooks(locale);
setIsDemo(false);
toast(clearT.success);
notifyLibraryUpdated();
} catch {
toast(clearT.failed);
} finally {
setClearingDemo(false);
}
};

return (
<div className="demo-banner" role="status">
<span className="demo-banner__text">
{isDemo ? t.demo : t.noKey}
</span>
{isDemo ? (
<button
type="button"
className="demo-banner__clear"
onClick={() => void handleClearDemo()}
disabled={clearingDemo}
aria-label={clearT.aria}
>
{clearingDemo ? clearT.clearing : clearT.label}
</button>
) : null}
<Link to="/settings" className="demo-banner__link">
{t.goToSettings}
</Link>
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/hooks/useBooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { fetchBooks } from "../api/books";
import { fetchBooks, LIBRARY_UPDATED_EVENT } from "../api/books";
import type { Locale } from "../i18n/locale";
import type { Book, BooksResponse, LibraryListSort } from "../types";

Expand Down Expand Up @@ -48,6 +48,12 @@ export function useBooks(
};
}, [locale, page, perPage, sort, tick]);

useEffect(() => {
const onUpdate = () => setTick((t) => t + 1);
window.addEventListener(LIBRARY_UPDATED_EVENT, onUpdate);
return () => window.removeEventListener(LIBRARY_UPDATED_EVENT, onUpdate);
}, []);

return {
books: data?.books ?? [],
total: data?.total ?? 0,
Expand Down
Loading
Loading