From 7c69487f60cc0e3f8bc96fde4a412e151cd4bf5d Mon Sep 17 00:00:00 2001 From: "i.gudz" Date: Sat, 28 Mar 2026 13:33:29 +0300 Subject: [PATCH 1/6] fix demo refresh after cleanup --- app/routers/books.py | 8 +++++++- app/services/demo_seed.py | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) 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..2052277 100644 --- a/app/services/demo_seed.py +++ b/app/services/demo_seed.py @@ -10,7 +10,7 @@ from app.services.library_db import get_all_books, 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 +500,22 @@ } 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 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) From 6dd25dcc31629407215264bd8bc95ad071838269 Mon Sep 17 00:00:00 2001 From: "i.gudz" Date: Sat, 28 Mar 2026 13:49:29 +0300 Subject: [PATCH 2/6] fix empty Profile page --- frontend/src/i18n/messages.ts | 11 ++++++++ frontend/src/pages/ProfilePage.tsx | 42 ++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 143300f..8cd8ce3 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -146,6 +146,9 @@ export type Messages = { emptySubBefore: string; emptySubLink: string; emptySubAfter: string; + emptyLibraryTitle: string; + emptyLibraryHint: string; + emptyLibraryCta: string; summary: string; noSummary: string; topGenres: string; @@ -331,6 +334,10 @@ const ru: Messages = { emptySubBefore: "Используется библиотека на вкладке", emptySubLink: "«Библиотека»", emptySubAfter: ". Нужно как минимум две книги.", + emptyLibraryTitle: "В библиотеке пока нет книг", + emptyLibraryHint: + "Профиль читателя собирается по книгам из библиотеки. Добавьте книги, затем снова откройте эту страницу.", + emptyLibraryCta: "Перейти в библиотеку", summary: "Кратко", noSummary: "Текста резюме нет.", topGenres: "Топ жанров", @@ -516,6 +523,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", 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}

+ ) : 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 1ae3604..8a102c5 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -178,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 = { @@ -371,6 +379,15 @@ const ru: Messages = { showKey: "Показать ключ", hideKey: "Скрыть ключ", }, + demoFab: { + label: "Убрать демо-книги", + aria: "Удалить все демонстрационные книги из библиотеки", + clearing: "Удаление…", + confirm: + "Удалить все демо-книги из библиотеки? Пустая библиотека не заполнится демо снова автоматически.", + success: "Демо-книги удалены", + failed: "Не удалось удалить демо-книги", + }, }; const en: Messages = { @@ -563,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 = { From d9ad022eb2a6d10ddeb31858af252d280deae85a Mon Sep 17 00:00:00 2001 From: "i.gudz" Date: Sat, 28 Mar 2026 21:31:40 +0300 Subject: [PATCH 5/6] script docker and local fixed --- Makefile | 11 ++++++++--- docker-compose.yml | 10 ++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) 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/docker-compose.yml b/docker-compose.yml index 257db00..1fb80de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,25 +25,27 @@ services: 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: From 070b475e51b09a75c37f5f84f292fdbfe5339ff2 Mon Sep 17 00:00:00 2001 From: "i.gudz" Date: Sat, 28 Mar 2026 21:43:11 +0300 Subject: [PATCH 6/6] minor version up --- app/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"