diff --git a/Makefile b/Makefile index 61c2883..3efd653 100644 --- a/Makefile +++ b/Makefile @@ -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" @@ -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) & \ @@ -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 \ diff --git a/app/main.py b/app/main.py index 17adab3..d3fb008 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -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"} diff --git a/app/routers/books.py b/app/routers/books.py index 7c0918a..241ea2f 100644 --- a/app/routers/books.py +++ b/app/routers/books.py @@ -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, @@ -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"} diff --git a/app/services/demo_seed.py b/app/services/demo_seed.py index 037cb07..bb957a2 100644 --- a/app/services/demo_seed.py +++ b/app/services/demo_seed.py @@ -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__) @@ -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) diff --git a/app/version.py b/app/version.py index ea15772..6be1f64 100644 --- a/app/version.py +++ b/app/version.py @@ -3,4 +3,4 @@ See https://semver.org/ """ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/docker-compose.yml b/docker-compose.yml index 7a3e14f..1fb80de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. @@ -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: diff --git a/frontend/src/api/books.ts b/frontend/src/api/books.ts index a48ba44..1cedbdd 100644 --- a/frontend/src/api/books.ts +++ b/frontend/src/api/books.ts @@ -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(url: string, init?: RequestInit): Promise { const res = await fetch(url, init); if (!res.ok) { @@ -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 { const form = new FormData(); for (const file of files) { diff --git a/frontend/src/components/DemoBanner.css b/frontend/src/components/DemoBanner.css index f68a51e..399a861 100644 --- a/frontend/src/components/DemoBanner.css +++ b/frontend/src/components/DemoBanner.css @@ -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); @@ -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; diff --git a/frontend/src/components/DemoBanner.tsx b/frontend/src/components/DemoBanner.tsx index 9432514..4856981 100644 --- a/frontend/src/components/DemoBanner.tsx +++ b/frontend/src/components/DemoBanner.tsx @@ -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"; @@ -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; @@ -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 (
{isDemo ? t.demo : t.noKey} + {isDemo ? ( + + ) : null} {t.goToSettings} diff --git a/frontend/src/hooks/useBooks.ts b/frontend/src/hooks/useBooks.ts index 227b650..649a676 100644 --- a/frontend/src/hooks/useBooks.ts +++ b/frontend/src/hooks/useBooks.ts @@ -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"; @@ -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, diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 143300f..8a102c5 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -107,8 +107,9 @@ export type Messages = { load: string; wait: string; refresh: string; - blockedEmpty: string; - syncLibrary: string; + emptyLibraryTitle: string; + emptyLibraryHint: string; + emptyLibraryCta: string; blockedMoreBooks: string; openLibrary: string; blockedEnrich: string; @@ -146,6 +147,9 @@ export type Messages = { emptySubBefore: string; emptySubLink: string; emptySubAfter: string; + emptyLibraryTitle: string; + emptyLibraryHint: string; + emptyLibraryCta: string; summary: string; noSummary: string; topGenres: string; @@ -174,6 +178,14 @@ export type Messages = { showKey: string; hideKey: string; }; + demoFab: { + label: string; + aria: string; + clearing: string; + confirm: string; + success: string; + failed: string; + }; }; const ru: Messages = { @@ -278,8 +290,10 @@ const ru: Messages = { load: "Загрузить", wait: "Подождите…", refresh: "Обновить", - blockedEmpty: "Здесь пока пустая библиотека.", - syncLibrary: "Синхронизировать библиотеку", + emptyLibraryTitle: "В библиотеке пока нет книг", + emptyLibraryHint: + "Персональные рекомендации строятся по книгам из вашей библиотеки. Добавьте книги, затем снова откройте эту страницу.", + emptyLibraryCta: "Перейти в библиотеку", blockedMoreBooks: "Добавьте ещё одну книгу и снова синхронизируйте.", openLibrary: "Открыть библиотеку", @@ -309,7 +323,8 @@ const ru: Messages = { "Договариваемся с музами и метаданными…", ], errorShort: { - need_sync: "Сначала синхронизируйте библиотеку.", + need_sync: + "В библиотеке пока нет книг. Добавьте книги, чтобы получить рекомендации.", need_books: "В библиотеке нужно как минимум две книги.", need_enrich: "Сначала выполните быстрый анализ библиотеки.", other: "Не удалось загрузить рекомендации.", @@ -331,6 +346,10 @@ const ru: Messages = { emptySubBefore: "Используется библиотека на вкладке", emptySubLink: "«Библиотека»", emptySubAfter: ". Нужно как минимум две книги.", + emptyLibraryTitle: "В библиотеке пока нет книг", + emptyLibraryHint: + "Профиль читателя собирается по книгам из библиотеки. Добавьте книги, затем снова откройте эту страницу.", + emptyLibraryCta: "Перейти в библиотеку", summary: "Кратко", noSummary: "Текста резюме нет.", topGenres: "Топ жанров", @@ -360,6 +379,15 @@ const ru: Messages = { showKey: "Показать ключ", hideKey: "Скрыть ключ", }, + demoFab: { + label: "Убрать демо-книги", + aria: "Удалить все демонстрационные книги из библиотеки", + clearing: "Удаление…", + confirm: + "Удалить все демо-книги из библиотеки? Пустая библиотека не заполнится демо снова автоматически.", + success: "Демо-книги удалены", + failed: "Не удалось удалить демо-книги", + }, }; const en: Messages = { @@ -464,8 +492,10 @@ const en: Messages = { load: "Load", wait: "Working…", refresh: "Refresh", - blockedEmpty: "Your library is empty here.", - syncLibrary: "Sync library", + emptyLibraryTitle: "Your library has no books yet", + emptyLibraryHint: + "Personal recommendations are based on the books in your library. Add some books, then open this page again.", + emptyLibraryCta: "Go to library", blockedMoreBooks: "Add one more book, then sync again.", openLibrary: "Open library", blockedEnrich: @@ -494,7 +524,8 @@ const en: Messages = { "Negotiating with muses and metadata…", ], errorShort: { - need_sync: "Sync your library first.", + need_sync: + "Your library has no books yet. Add books to get recommendations.", need_books: "You need at least two books in your library.", need_enrich: "Run a quick analysis on your library.", other: "Could not load recommendations.", @@ -516,6 +547,10 @@ const en: Messages = { emptySubBefore: "Uses your library on the", emptySubLink: "Library", emptySubAfter: "tab. You need at least two books.", + emptyLibraryTitle: "Your library has no books yet", + emptyLibraryHint: + "Your reader profile is built from the books in your library. Add some books, then open this page again.", + emptyLibraryCta: "Go to library", summary: "Summary", noSummary: "No summary text.", topGenres: "Top genres", @@ -545,6 +580,15 @@ const en: Messages = { showKey: "Show key", hideKey: "Hide key", }, + demoFab: { + label: "Remove demo books", + aria: "Remove all demonstration books from the library", + clearing: "Removing…", + confirm: + "Remove all demo books from your library? An empty library will not be auto-filled with demo again.", + success: "Demo books removed", + failed: "Could not remove demo books", + }, }; export const messagesByLocale: Record = { diff --git a/frontend/src/lib/recBlocker.ts b/frontend/src/lib/recBlocker.ts index 7e33322..5bd0481 100644 --- a/frontend/src/lib/recBlocker.ts +++ b/frontend/src/lib/recBlocker.ts @@ -11,6 +11,8 @@ export function recBlockerFromMessage(message: string): RecBlocker { if ( m.includes("no library found") || m.includes("library is empty") || + m.includes("no books in library") || + m.includes("import .md files first") || m.includes("sync first") ) { return { kind: "need_sync" }; diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 89c281c..1567df3 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { fetchLibraryReadiness } from "../api/readiness"; import { buildProfile, fetchProfile } from "../api/recommend"; import { IndeterminateProgress } from "../components/IndeterminateProgress"; import { useToast } from "../components/Toast"; @@ -32,6 +33,17 @@ function topNMoods(moods: string[], n: number): string[] { return u.slice(0, n); } +/** True when GET /profile failed because the library has no books (readiness may be unavailable). */ +function isEmptyLibraryProfileError(msg: string): boolean { + if (!msg.startsWith("400")) return false; + const lower = msg.toLowerCase(); + return ( + lower.includes("no books in library") || + lower.includes("library is empty") || + lower.includes("import .md files first") + ); +} + export function ProfilePage() { const { toast } = useToast(); const { m, locale } = useI18n(); @@ -41,17 +53,32 @@ export function ProfilePage() { const [loading, setLoading] = useState(true); const [building, setBuilding] = useState(false); const [missing, setMissing] = useState(false); + const [noBooks, setNoBooks] = useState(false); const { runPrepare, preparing, phase } = useLibraryPrepare(locale); const load = useCallback(async () => { setLoading(true); setMissing(false); + setNoBooks(false); try { + try { + const readiness = await fetchLibraryReadiness(locale); + if (readiness.book_count === 0) { + setProfile(null); + setNoBooks(true); + return; + } + } catch { + // If readiness fails, still try loading the profile. + } const p = await fetchProfile(locale); setProfile(p); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - if (msg.startsWith("404")) { + if (isEmptyLibraryProfileError(msg)) { + setProfile(null); + setNoBooks(true); + } else if (msg.startsWith("404")) { setProfile(null); setMissing(true); } else { @@ -87,6 +114,7 @@ export function ProfilePage() { const p = await buildProfile(locale, true); setProfile(p); setMissing(false); + setNoBooks(false); toast(m.profile.rebuilt); } catch (e) { toast( @@ -149,7 +177,17 @@ export function ProfilePage() {
) : null} - {missing && !loading ? ( + {noBooks && !loading ? ( +
+

{m.profile.emptyLibraryTitle}

+

{m.profile.emptyLibraryHint}

+ + {m.profile.emptyLibraryCta} + +
+ ) : null} + + {missing && !loading && !noBooks ? (

{m.profile.emptyTitle}