Compare commits

..
5 Commits
Author SHA1 Message Date
John O'Keefe a093529b80 chore(deps): pin foliate-js f622841 — manual touch scrolling in scrolled layouts
Release / build-and-push (push) Successful in 2m56s
Completes the reader-chrome UX batch (handoff item 7): the gitea
mirror now carries fork 0.5.4, so the pin replaces the temporary
node_modules patch the verification build used. Rebuilt and re-verified
live (chrome starts hidden; flick-down reveals).
2026-10-15 14:33:30 -04:00
John O'Keefe f2fd4db81c feat(web): reader chrome UX — intent-based reveal, reading actions dismiss (handoff item 7)
The chrome previously woke on ANY activity: pointermove (mouse drift
and, worse, the touch drag of a page turn itself), every keypress, and
every touch tap toggled it. Now reveal requires explicit UI intent and
reading actions suppress:

- Desktop: cursor into the top 10% / bottom 8% band raises the chrome
  (screenY-based so content iframes classify identically); content
  pointer movement never does. Mouse center-clicks no longer toggle.
- Touch: a vertical flick (dominant + fast) reveals; while visible,
  flick down OR up dismisses. Plain taps are fully inert — a stray tap
  can never flash the UI.
- Keyboard: page keys never poke; turning (keys, mouse edge zones, or
  a touch swipe via the snap-relocate) actively dismisses visible
  chrome.
- Highlight saves return to a clean screen: the selection flow owns
  the screen and the post-save pointer/keyboard pokes are gone.
- Chrome starts hidden; always-visible pin and auto-hide timer remain
  (bands use a shorter 1.2s fade).

Pairs with the foliate-js 0.5.4 paginator change (scrolled layouts
scroll manually so the flick gesture is observable in webtoon); the
fork pin bump follows once the gitea mirror shows f622841.
2026-10-15 13:44:19 -04:00
John O'Keefe 2517baedea feat(koreader): device bridge for ratings, reviews and reading-time stats
Release / build-and-push (push) Successful in 3m4s
Extends the device-authed koreader surface with the handoff item 4
Batch B contract:

- GET/POST /api/sync/koreader/rating — the device mirrors the .sdr
  summary's 5-star rating against the 1-10 server scale (POST 0 clears).
- GET/POST /api/sync/koreader/review — raw-markdown review mirror
  (summary.note); empty body clears, matching the first-party PUT.
- POST /api/sync/koreader/stats — the device pushes its CUMULATIVE
  total reading seconds; the server watermark (highest total ever
  converted, read from the koreader sessions' own device_metadata)
  converts only the delta into a reading_history session attributed
  to the koreader device. Reinstalls cannot double-count; sub-minute
  deltas ride forward (no session, no watermark movement); a lagging
  device clamps to zero instead of dragging the total back.

Book identity resolves through the existing priority chain (uuid →
sha256 → alias → title/author). GET binding uses query tags; POST
endpoints bind the ref+payload once (an earlier resolve-then-bind
draft drained the body).

Live-verified against the dev server with the koreader device token:
rating set/get/clear, review mirror, and the stats sequence
900→session, 960→delta session, 970→rides forward, 500→clamps; the
book's stats card and the By Device panel both pick up the split.
2026-10-15 11:26:47 -04:00
John O'Keefe 2117ec6181 feat(web): book reviews — markdown, sibling of ratings, full API + page surface
media_reviews table mirrors media_ratings' shape (unique per user+item,
cascade deletes, updated_at for LWW) but standalone: a review without a
rating must work. Body is RAW MARKDOWN — storage and every payload carry
the source; rendering happens only at display sites (same doctrine as
notes, same sanitization pipeline: markdown-it + DOMPurify).

API: GET/PUT/DELETE /api/media-items/:id/review (PUT upserts; an empty
body deletes — clearing via an editor is a delete, not an empty row).
Book-detail page payload now carries the review beside the rating.

Page: the review renders above the synopsis as a scrolling card (same
max-height box as the summary), hydrated client-side through
renderNoteHtml; 'Write review'/'Edit review' sits next to the rating
stars and opens a raw-markdown editor dialog (save / delete / cancel).

sqlc regenerated; schema applied to the dev DB. Live-verified: upsert,
get, empty-delete, page markup, and the app round-trip against the dev
server.
2026-10-15 10:53:53 -04:00
John O'Keefe fd6f0e3353 fix(web): scan progress — correct percentages, no layout shift, refresh-survivable
Bug: the admin 'Scanning Libraries' widget fed the server's 0-1
progress straight into percent strings and bar widths, so the overall
and per-library percentages capped at '1%' and the bars never filled.
Both now scale by 100 (matching the setup page, which was already
correct).

Bug: scan state only reached clients via websocket events, so a page
opened (or refreshed) mid-scan showed nothing until the next tick —
and the admin widget never re-attached at all. The worker now exposes
active jobs (with their library_id so rows keep their names), served
as GET /api/scanner/active (admin, re-attaches the widget + polling)
and GET /api/scanner/active-summary (any user, restores the sidebar
indicator on load). Idle hides both, exactly as before.

Annoyance: the sidebar's 'Scanning… N%' was a block element between
the logo and the nav, shoving every menu entry down while a scan ran.
The percent now lives inside the fixed-height logo row next to the
spinner (spinner last, so growing digits extend leftward); nothing
below it moves.
2026-10-15 08:29:04 -04:00
28 changed files with 1382 additions and 419 deletions

No files matched your search

+18
View File
@@ -345,6 +345,20 @@ CREATE TABLE IF NOT EXISTS media_ratings (
UNIQUE(media_item_id, user_id)
);
-- Create media_reviews table: the user's markdown book review. Sibling of
-- media_ratings (same uniqueness + cascade shape) but standalone — a review
-- without a rating (and vice versa) must work. Stored as RAW markdown and
-- rendered only at display sites (same doctrine as notes).
CREATE TABLE IF NOT EXISTS media_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
review TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(media_item_id, user_id)
);
-- Create media_notes table for user notes on media items
CREATE TABLE IF NOT EXISTS media_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -602,6 +616,10 @@ CREATE INDEX IF NOT EXISTS idx_reading_progress_user_id ON reading_progress(user
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);
-- Reviews indexes
CREATE INDEX IF NOT EXISTS idx_media_reviews_media_item_id ON media_reviews(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_reviews_user_id ON media_reviews(user_id);
-- Notes and highlights indexes
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
package database
+10 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
package database
@@ -352,6 +352,15 @@ type MediaRatings struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type MediaReviews struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Review string `db:"review" json:"review"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type OpdsTokens struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
+10 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
package database
@@ -142,6 +142,7 @@ type Querier interface {
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
@@ -234,6 +235,12 @@ type Querier interface {
GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error)
// Get Kobo shelves by collection
GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error)
// KOReader stats conversion: the device pushes its CUMULATIVE total reading
// seconds for a book; the watermark is the highest total already converted,
// read from the koreader sessions' own device_metadata. Delta-only insertion
// means a reinstall of the device cannot double-count, and sub-threshold
// deltas are never lost (they stay folded into the next push's delta).
GetKoreaderStatWatermark(ctx context.Context, arg GetKoreaderStatWatermarkParams) (interface{}, error)
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error)
@@ -299,6 +306,7 @@ type Querier interface {
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error)
GetNextRetryTime(ctx context.Context) (interface{}, error)
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
// Get OPDS token
@@ -573,6 +581,7 @@ type Querier interface {
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
+93 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
// source: queries.sql
package database
@@ -2262,6 +2262,20 @@ func (q *Queries) DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingPa
return err
}
const DeleteMediaReview = `-- name: DeleteMediaReview :exec
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
`
type DeleteMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error {
_, err := q.db.Exec(ctx, DeleteMediaReview, arg.MediaItemID, arg.UserID)
return err
}
const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one
DELETE FROM processing_issues
WHERE id = $1
@@ -4610,6 +4624,31 @@ func (q *Queries) GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShe
return items, nil
}
const GetKoreaderStatWatermark = `-- name: GetKoreaderStatWatermark :one
SELECT COALESCE(MAX((device_metadata->>'total_seconds')::bigint), 0) AS total_seconds
FROM reading_history
WHERE user_id = $1
AND media_item_id = $2
AND device_metadata->>'source' = 'koreader'
`
type GetKoreaderStatWatermarkParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
}
// KOReader stats conversion: the device pushes its CUMULATIVE total reading
// seconds for a book; the watermark is the highest total already converted,
// read from the koreader sessions' own device_metadata. Delta-only insertion
// means a reinstall of the device cannot double-count, and sub-threshold
// deltas are never lost (they stay folded into the next push's delta).
func (q *Queries) GetKoreaderStatWatermark(ctx context.Context, arg GetKoreaderStatWatermarkParams) (interface{}, error) {
row := q.db.QueryRow(ctx, GetKoreaderStatWatermark, arg.UserID, arg.MediaItemID)
var total_seconds interface{}
err := row.Scan(&total_seconds)
return total_seconds, err
}
const GetLibrary = `-- name: GetLibrary :one
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
FROM libraries l
@@ -6518,6 +6557,29 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
return items, nil
}
const GetMediaReview = `-- name: GetMediaReview :one
SELECT id, media_item_id, user_id, review, created_at, updated_at FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
`
type GetMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error) {
row := q.db.QueryRow(ctx, GetMediaReview, arg.MediaItemID, arg.UserID)
var i MediaReviews
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.Review,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetNextRetryTime = `-- name: GetNextRetryTime :one
SELECT
CASE
@@ -14976,6 +15038,36 @@ func (q *Queries) UpsertGlobalDashboardPreferences(ctx context.Context, arg Upse
return i, err
}
const UpsertMediaReview = `-- name: UpsertMediaReview :one
INSERT INTO media_reviews (media_item_id, user_id, review)
VALUES ($1, $2, $3)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
review = EXCLUDED.review,
updated_at = NOW()
RETURNING id, media_item_id, user_id, review, created_at, updated_at
`
type UpsertMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Review string `db:"review" json:"review"`
}
func (q *Queries) UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error) {
row := q.db.QueryRow(ctx, UpsertMediaReview, arg.MediaItemID, arg.UserID, arg.Review)
var i MediaReviews
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.Review,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpsertPanelData = `-- name: UpsertPanelData :one
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
VALUES ($1, $2, $3, $4)
+27
View File
@@ -550,6 +550,33 @@ RETURNING *;
-- name: DeleteMediaRating :exec
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
-- name: UpsertMediaReview :one
INSERT INTO media_reviews (media_item_id, user_id, review)
VALUES ($1, $2, $3)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
review = EXCLUDED.review,
updated_at = NOW()
RETURNING *;
-- name: GetMediaReview :one
SELECT * FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
-- name: DeleteMediaReview :exec
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
-- KOReader stats conversion: the device pushes its CUMULATIVE total reading
-- seconds for a book; the watermark is the highest total already converted,
-- read from the koreader sessions' own device_metadata. Delta-only insertion
-- means a reinstall of the device cannot double-count, and sub-threshold
-- deltas are never lost (they stay folded into the next push's delta).
-- name: GetKoreaderStatWatermark :one
SELECT COALESCE(MAX((device_metadata->>'total_seconds')::bigint), 0) AS total_seconds
FROM reading_history
WHERE user_id = $1
AND media_item_id = $2
AND device_metadata->>'source' = 'koreader';
-- Search Media Items queries
-- name: SearchMediaItems :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name
+260
View File
@@ -6,6 +6,7 @@ import (
wsync "bookhoard/internal/sync"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -14,6 +15,8 @@ import (
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
@@ -1729,3 +1732,260 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
"timestamp": time.Now().Format(time.RFC3339),
})
}
// ---------------------------------------------------------------------------
// Ratings / review / reading-time bridge (handoff item 4 Batch B)
//
// The plugin mirrors the book's .sdr summary (summary.rating 5-star,
// summary.note = the Book Status "Review" box) against the server's
// 1-10 rating and raw-markdown review, and pushes its cumulative
// reading time from statistics.sqlite3. Stats are PUSH-ONLY and
// watermark-converted: the device sends its TOTAL seconds for the
// book and the server inserts only the delta as a reading_history
// session attributed to this device, so totals unify in the one
// stats pipeline while the By Device panel keeps the per-client
// split. A device reinstall cannot double-count (the watermark is
// server-side), and sub-threshold deltas simply ride forward.
// ---------------------------------------------------------------------------
// koreaderBookRef is the minimal book identity the bridge endpoints
// accept; it resolves through the same priority chain as progress.
type koreaderBookRef struct {
UUID string `json:"uuid,omitempty" query:"uuid"`
SHA256 string `json:"sha256,omitempty" query:"sha256"`
Title string `json:"title,omitempty" query:"title"`
Authors []string `json:"authors,omitempty"`
}
func (r koreaderBookRef) toProgress() KOReaderBookProgress {
return KOReaderBookProgress{
UUID: r.UUID,
SHA256: r.SHA256,
Title: r.Title,
Authors: r.Authors,
}
}
// resolveParsedRef resolves an already-bound book identity to a media
// item. Endpoints bind ONCE — a resolve helper that re-bound the body
// would drain it and break the payload bind that follows.
func (h *KOReaderHandler) resolveParsedRef(c *echo.Context, device database.Devices, ref koreaderBookRef) (pgtype.UUID, error) {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, device.UserID, ref.toProgress())
if !mediaItemID.Valid {
return pgtype.UUID{}, fmt.Errorf("book not found on server")
}
return mediaItemID, nil
}
// GetKOReaderRating handles GET /api/sync/koreader/rating?uuid=&sha256=
func (h *KOReaderHandler) GetKOReaderRating(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var ref koreaderBookRef
if err := c.Bind(&ref); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
mediaItemID, err := h.resolveParsedRef(c, device, ref)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
rating, err := h.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": rating.Rating})
}
// SetKOReaderRating handles POST /api/sync/koreader/rating. Rating 0
// (koreader's "unrated") clears the server rating.
func (h *KOReaderHandler) SetKOReaderRating(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
Rating int `json:"rating" validate:"min=0,max=10"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
if req.Rating <= 0 {
if err := h.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
rating, err := h.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
Rating: int32(req.Rating),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"rating": rating.Rating})
}
// GetKOReaderReview handles GET /api/sync/koreader/review. Returns the
// RAW MARKDOWN source (koreader's review box shows it literally, the
// same accepted tradeoff as notes).
func (h *KOReaderHandler) GetKOReaderReview(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var ref koreaderBookRef
if err := c.Bind(&ref); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
mediaItemID, err := h.resolveParsedRef(c, device, ref)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
review, err := h.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": review.Review})
}
// SetKOReaderReview handles POST /api/sync/koreader/review. An empty
// body clears the review (mirrors the first-party PUT contract).
func (h *KOReaderHandler) SetKOReaderReview(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
Review string `json:"review" validate:"max=50000"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
if strings.TrimSpace(req.Review) == "" {
if err := h.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
review, err := h.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{
MediaItemID: mediaItemID,
UserID: device.UserID,
Review: req.Review,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": review.Review})
}
// PushKOReaderStats handles POST /api/sync/koreader/stats. The device
// sends its cumulative total reading seconds for the book; only the
// delta above the stored watermark becomes a reading_history session
// (attributed to the koreader device, metadata carrying the new
// cumulative marker). Sub-minute deltas ride forward: no session, no
// watermark movement, nothing lost.
func (h *KOReaderHandler) PushKOReaderStats(c *echo.Context) error {
device := c.Get("device").(database.Devices)
var req struct {
koreaderBookRef
TotalSeconds int64 `json:"total_seconds" validate:"min=0,max=2000000000"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
// sqlc scans COALESCE(MAX(...)) as interface{}; pgx hands bigint
// back as int64.
watermarkAny, err := h.db.GetKoreaderStatWatermark(c.Request().Context(), database.GetKoreaderStatWatermarkParams{
UserID: device.UserID,
MediaItemID: mediaItemID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
watermark, _ := watermarkAny.(int64)
// Delta against the stored watermark (the highest total ever
// converted for this user+book across ALL koreader devices). When
// another device is ahead, the delta clamps to zero so a lagging
// device can never drag the total backwards.
delta := req.TotalSeconds - watermark
if delta < 0 {
delta = 0
}
result := map[string]interface{}{
"total_seconds": req.TotalSeconds,
"watermark": watermark,
"delta_seconds": delta,
"session_recorded": false,
}
if delta >= 60 {
now := time.Now()
start := now.Add(-time.Duration(delta) * time.Second)
metadata, _ := json.Marshal(map[string]interface{}{
"source": "koreader",
"total_seconds": req.TotalSeconds,
})
if _, err := h.db.CreateReadingHistory(c.Request().Context(), database.CreateReadingHistoryParams{
UserID: device.UserID,
MediaItemID: mediaItemID,
DeviceID: device.ID,
ProgressPercentage: pgtype.Float8{Float64: 0, Valid: false},
ReadingSessionStart: pgtype.Timestamptz{Time: start, Valid: true},
ReadingSessionEnd: pgtype.Timestamptz{Time: now, Valid: true},
PagesRead: pgtype.Int4{Int32: 0, Valid: false},
TimeSpentSeconds: pgtype.Int4{Int32: int32(delta), Valid: true},
DeviceMetadata: metadata,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
result["session_recorded"] = true
result["session_seconds"] = delta
}
return c.JSON(http.StatusOK, result)
}
+105
View File
@@ -1012,6 +1012,111 @@ func (mh *MediaHandler) DeleteMediaRating(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"})
}
// UpsertMediaReview handles POST and PUT /api/media-items/:id/review.
// The review body is RAW MARKDOWN (same doctrine as notes): storage and
// every sync payload carry the source string; rendering happens only at
// display sites. An empty body deletes the review — clearing via the
// editor is a delete, not an empty row.
func (mh *MediaHandler) UpsertMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req struct {
Review string `json:"review" validate:"max=50000"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
trimmed := strings.TrimSpace(req.Review)
if trimmed == "" {
err := mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
review, err := mh.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Review: req.Review,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, review)
}
// GetMediaReview handles GET /api/media-items/:id/review
func (mh *MediaHandler) GetMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
review, err := mh.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, review)
}
// DeleteMediaReview handles DELETE /api/media-items/:id/review
func (mh *MediaHandler) DeleteMediaReview(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
err = mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "review deleted"})
}
// GetMediaReadingProgress handles GET /api/media-items/:id/progress
func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
userID := c.Get("user_id").(string)
+1
View File
@@ -13,6 +13,7 @@ type MediaDetail struct {
// User-specific data
Rating *database.MediaRatings `json:"rating,omitempty"`
Review *database.MediaReviews `json:"review,omitempty"` // raw markdown
Collections []database.Collections `json:"collections"`
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
+32
View File
@@ -177,6 +177,38 @@ func (h *Handler) GetScanStatus(c *echo.Context) error {
})
}
// GetActiveScans handles GET /api/scanner/active (admin): every
// pending/running job. Lets the admin page re-attach to in-flight
// scans after a page refresh instead of only tracking scans it
// started itself in this page load.
func (h *Handler) GetActiveScans(c *echo.Context) error {
jobs := h.worker.GetActiveScanJobs()
return c.JSON(http.StatusOK, map[string]interface{}{
"scanning": len(jobs) > 0,
"jobs": jobs,
})
}
// GetScanSummary handles GET /api/scanner/active-summary (any
// authenticated user): whether a scan is running and its average
// progress (0-1). Powers the sitewide sidebar indicator, which must
// survive page refreshes — websocket events alone only reach pages
// that were already open when the scan started.
func (h *Handler) GetScanSummary(c *echo.Context) error {
jobs := h.worker.GetActiveScanJobs()
progress := 0.0
if len(jobs) > 0 {
for _, job := range jobs {
progress += job.Progress
}
progress /= float64(len(jobs))
}
return c.JSON(http.StatusOK, map[string]interface{}{
"scanning": len(jobs) > 0,
"progress": progress,
})
}
// StartWatchModeForLibrary starts watching a specific library's folders
func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error {
h.mu.Lock()
+12
View File
@@ -1603,6 +1603,17 @@ func registerFrontendRoutes(cfg *Config) {
rating = &userRating
}
// Fetch the user's review (raw markdown; rendered client-side
// through the notes pipeline).
var review *database.MediaReviews
userReview, err := cfg.Queries.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
})
if err == nil {
review = &userReview
}
// Fetch collections
collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID)
@@ -1676,6 +1687,7 @@ func registerFrontendRoutes(cfg *Config) {
MediaItems: mediaItem, // Embedded - ALL fields available
FileLocation: fileLocation,
Rating: rating,
Review: review,
Collections: collections,
ReadingProgress: progress,
BookStats: bookStats,
+7
View File
@@ -25,6 +25,13 @@ func registerMediaRoutes(cfg *Config) {
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating)
// Media review routes (all authenticated users). Raw markdown in,
// raw markdown out — rendered only at display sites.
protected.POST("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
protected.GET("/media-items/:id/review", cfg.MediaHandler.GetMediaReview)
protected.PUT("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
protected.DELETE("/media-items/:id/review", cfg.MediaHandler.DeleteMediaReview)
// Progress routes (all authenticated users)
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)
+2 -2
View File
@@ -332,9 +332,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
// Register progress routes with actual handler
registerProgressRoutes(cfg, scannerHandler)
// Register scanner routes (admin only)
// Register scanner routes (admin only, plus the sitewide summary)
admin := protected.Group("", handlers.AdminMiddleware)
registerScannerRoutes(admin, scannerHandler)
registerScannerRoutes(protected, admin, scannerHandler)
// Hash conflict routes (admin only)
admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
+7 -1
View File
@@ -7,12 +7,18 @@ import (
)
// registerScannerRoutes registers all scanner-related endpoints
func registerScannerRoutes(admin *echo.Group, h *handlers.Handler) {
func registerScannerRoutes(protected, admin *echo.Group, h *handlers.Handler) {
// Any authenticated user: the sitewide sidebar indicator needs to
// know a scan is running after a page refresh (websocket events
// only reach pages that were already open).
protected.GET("/scanner/active-summary", h.GetScanSummary)
// Scanner routes (admin only)
admin.POST("/scanner/scan", h.ScanLibrary)
admin.POST("/scanner/start", h.StartScanner)
admin.POST("/scanner/stop", h.StopScanner)
admin.GET("/scanner/status/:jobId", h.GetScanStatus)
admin.GET("/scanner/active", h.GetActiveScans)
// Watch mode routes (admin only)
admin.POST("/scanner/watch/start", h.StartWatchMode)
+5
View File
@@ -28,6 +28,11 @@ func registerSyncRoutes(cfg *Config) {
koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata))
koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary))
koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks))
koreaderSync.GET("/rating", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetKOReaderRating))
koreaderSync.POST("/rating", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SetKOReaderRating))
koreaderSync.GET("/review", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetKOReaderReview))
koreaderSync.POST("/review", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SetKOReaderReview))
koreaderSync.POST("/stats", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.PushKOReaderStats))
// Kobo sync routes (device authentication required)
// Kobo devices use URL path: /api/sync/kobo/{token}/markup
+30 -6
View File
@@ -73,8 +73,12 @@ func (j *Job) UpdateProgress(progress float64, filesScanned, newItems, errors in
}
type JobResult struct {
JobID string `json:"job_id"`
Status JobStatus `json:"status"`
JobID string `json:"job_id"`
Status JobStatus `json:"status"`
// LibraryID carries the scan job's library (from Params) so clients
// can label progress rows after a page refresh, when the original
// start-request context is gone.
LibraryID string `json:"library_id,omitempty"`
Error string `json:"error"`
Result interface{} `json:"result"`
Progress float64 `json:"progress"`
@@ -237,10 +241,12 @@ func (w *Worker) worker() {
}
func (w *Worker) processJob(job *Job) {
libID, _ := job.Params["library_id"].(string)
w.mu.Lock()
w.results[job.ID] = &JobResult{
JobID: job.ID,
Status: JobStatusRunning,
JobID: job.ID,
Status: JobStatusRunning,
LibraryID: libID,
}
w.mu.Unlock()
@@ -313,8 +319,9 @@ func (w *Worker) processJob(job *Job) {
}
w.results[job.ID] = &JobResult{
JobID: job.ID,
Status: status,
JobID: job.ID,
Status: status,
LibraryID: libID,
Error: func() string {
if err != nil {
return err.Error()
@@ -981,6 +988,23 @@ func (w *Worker) GetJobStatus(jobID string) (*JobResult, bool) {
return result, exists
}
// GetActiveScanJobs returns snapshots of every pending/running job.
// Clients use it to re-attach to in-flight scans (e.g. after a page
// refresh mid-scan) instead of only learning about scans from
// websocket events.
func (w *Worker) GetActiveScanJobs() []JobResult {
w.mu.RLock()
defer w.mu.RUnlock()
active := make([]JobResult, 0)
for _, result := range w.results {
if result.Status == JobStatusRunning || result.Status == JobStatusPending {
active = append(active, *result)
}
}
return active
}
func (w *Worker) CancelJob(jobID string) error {
w.mu.Lock()
defer w.mu.Unlock()
+60
View File
@@ -0,0 +1,60 @@
package services
import (
"testing"
)
// GetActiveScanJobs backs the refresh-survival contract: a client that
// loads the page mid-scan must be able to discover pending/running jobs
// (with their library) rather than only learning about scans from
// websocket events emitted after the page opened.
func TestGetActiveScanJobs(t *testing.T) {
w := NewWorker(1, nil)
seed := map[string]*JobResult{
"running-scan": {JobID: "running-scan", Status: JobStatusRunning, LibraryID: "lib-1", Progress: 0.42},
"pending-scan": {JobID: "pending-scan", Status: JobStatusPending, LibraryID: "lib-2"},
"done-scan": {JobID: "done-scan", Status: JobStatusCompleted, LibraryID: "lib-3"},
"failed-scan": {JobID: "failed-scan", Status: JobStatusFailed},
"cancelled": {JobID: "cancelled", Status: JobStatusCancelled},
}
w.mu.Lock()
w.results = seed
w.mu.Unlock()
active := w.GetActiveScanJobs()
if len(active) != 2 {
t.Fatalf("expected 2 active jobs, got %d (%+v)", len(active), active)
}
seen := map[string]JobResult{}
for _, job := range active {
seen[job.JobID] = job
}
running, ok := seen["running-scan"]
if !ok {
t.Fatal("running scan missing from active jobs")
}
if running.LibraryID != "lib-1" {
t.Errorf("expected library_id lib-1, got %q", running.LibraryID)
}
if running.Progress != 0.42 {
t.Errorf("expected progress 0.42, got %v", running.Progress)
}
if _, ok := seen["pending-scan"]; !ok {
t.Error("pending scan missing from active jobs")
}
for _, id := range []string{"done-scan", "failed-scan", "cancelled"} {
if _, ok := seen[id]; ok {
t.Errorf("terminal job %q must not appear as active", id)
}
}
}
func TestGetActiveScanJobsEmpty(t *testing.T) {
w := NewWorker(1, nil)
if jobs := w.GetActiveScanJobs(); len(jobs) != 0 {
t.Fatalf("expected no active jobs, got %d", len(jobs))
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"dev": "npm run build:ts:dev && npm run build:css"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#7796b05",
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"dompurify": "^3.4.16",
+1 -1
View File
@@ -10,7 +10,7 @@ templ Admin(user User, stats AdminStats) {
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="admin" x-init="loadWatchStatus()" class="theme-{ user.Theme }">
<body x-data="admin" x-init="loadWatchStatus(); checkActiveScans()" class="theme-{ user.Theme }">
@Header(user, "/admin")
<main class="p-8">
<div class="max-w-4xl">
+1 -1
View File
@@ -29,7 +29,7 @@ func Admin(user User, stats AdminStats) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus()\" class=\"theme-{ user.Theme }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus(); checkActiveScans()\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+52
View File
@@ -154,6 +154,14 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
>Clear</button>
<!-- Review editor: markdown, same doctrine as notes -->
<button
type="button"
@click="openReviewEditor()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
x-text="hasReview ? 'Edit review' : 'Write review'"
></button>
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
@@ -214,6 +222,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
</div>
</div>
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
<!-- My Review (raw markdown hydrated client-side through the
notes pipeline — same sanitization as every note display) -->
if book.Review != nil && book.Review.Review != "" {
<div id="review-raw" class="hidden">{ book.Review.Review }</div>
<div id="review-card" class="card p-6 mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">My Review</h3>
<div
id="review-body"
class="note-rich max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed"
style="color: var(--text-secondary)"
x-html="reviewHtml"
></div>
</div>
}
<!-- Synopsis -->
if book.Description.Valid && book.Description.String != "" {
<div class="card p-6 mb-6">
@@ -477,6 +499,36 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if user.Role == "admin" {
@MetadataEditorModal(book)
}
<!-- Review editor: plain textarea over the RAW markdown (what
storage and sync carry); rendering happens only on display. -->
<div
x-show="reviewOpen"
x-cloak
class="fixed inset-0 flex items-center justify-center z-50 p-4"
style="background-color: var(--surface-overlay);"
@keydown.escape.window="reviewOpen = false"
>
<div class="absolute inset-0" @click="reviewOpen = false"></div>
<div class="relative card p-6 w-full max-w-2xl">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Edit Review</h3>
<textarea
x-model="reviewDraft"
rows="12"
class="w-full rounded-xl border p-3 text-sm font-mono resize-y"
style="background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);"
placeholder="Markdown supported — **bold**, lists, footnotes…"
></textarea>
<p class="mt-1 text-xs" style="color: var(--text-secondary)">Markdown — renders like notes. Saving an empty review deletes it.</p>
<div class="mt-4 flex items-center gap-2">
<button type="button" @click="saveReview()" :disabled="reviewSaving" class="btn btn-primary">
<span x-show="!reviewSaving">Save review</span>
<span x-show="reviewSaving">Saving…</span>
</button>
<button type="button" @click="deleteReview()" :disabled="reviewSaving || !hasReview" class="btn btn-secondary" style="color: var(--status-danger, #e5484d);">Delete</button>
<button type="button" @click="reviewOpen = false" class="btn btn-secondary ml-auto">Cancel</button>
</div>
</div>
</div>
@ErrorToast(errorMessage)
</body>
</html>
File diff suppressed because it is too large. Load diff
+19 -13
View File
@@ -24,11 +24,24 @@ templ Header(user User, currentPath string) {
@Icon("book-open", "h-6 w-6")
</span>
<span class="font-bold text-lg tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
<!-- Scanning indicator: lives INSIDE the fixed-height logo row so
it can never push the nav entries down. Spinner sits last so
the growing percent extends leftward, not into the spinner. -->
<div
x-show="scanning"
x-cloak
x-transition
class="ml-auto flex items-center gap-1.5 shrink-0"
aria-live="polite"
>
<span
x-show="scanProgress > 0"
class="text-xs font-medium"
style="color: var(--text-secondary);"
x-text="scanProgress + '%'"
></span>
<svg
x-show="scanning"
x-cloak
x-transition
class="h-4 w-4 animate-spin ml-auto"
class="h-4 w-4 animate-spin"
style="color: var(--accent);"
fill="none"
viewBox="0 0 24 24"
@@ -36,15 +49,8 @@ templ Header(user User, currentPath string) {
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</a>
<span
x-show="scanning && scanProgress > 0"
x-cloak
x-transition
class="block px-5 py-1.5 text-xs font-medium -mt-1"
style="color: var(--text-secondary);"
x-text="'Scanning… ' + scanProgress + '%'"
></span>
</div>
</a>
<!-- Primary navigation -->
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }>
+11 -11
View File
@@ -37,7 +37,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"font-bold text-lg tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span> <svg x-show=\"scanning\" x-cloak x-transition class=\"h-4 w-4 animate-spin ml-auto\" style=\"color: var(--accent);\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path></svg></a> <span x-show=\"scanning && scanProgress > 0\" x-cloak x-transition class=\"block px-5 py-1.5 text-xs font-medium -mt-1\" style=\"color: var(--text-secondary);\" x-text=\"'Scanning… ' + scanProgress + '%'\"></span><!-- Primary navigation --><nav class=\"flex-1 px-3 py-4 space-y-0.5 overflow-y-auto\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"font-bold text-lg tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span><!-- Scanning indicator: lives INSIDE the fixed-height logo row so\n\t\t\t it can never push the nav entries down. Spinner sits last so\n\t\t\t the growing percent extends leftward, not into the spinner. --><div x-show=\"scanning\" x-cloak x-transition class=\"ml-auto flex items-center gap-1.5 shrink-0\" aria-live=\"polite\"><span x-show=\"scanProgress > 0\" class=\"text-xs font-medium\" style=\"color: var(--text-secondary);\" x-text=\"scanProgress + '%'\"></span> <svg class=\"h-4 w-4 animate-spin\" style=\"color: var(--accent);\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path></svg></div></a><!-- Primary navigation --><nav class=\"flex-1 px-3 py-4 space-y-0.5 overflow-y-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -290,7 +290,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 107, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 113, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -303,7 +303,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 109, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 115, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -316,7 +316,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 112, Col: 130}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 118, Col: 130}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -329,7 +329,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 113, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 119, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -382,7 +382,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 124, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 130, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -395,7 +395,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 126, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 132, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -418,7 +418,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 132, Col: 191}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 138, Col: 191}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -436,7 +436,7 @@ func Header(user User, currentPath string) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 134, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 140, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -676,7 +676,7 @@ func SidebarUserMenu(user User) templ.Component {
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 224, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 230, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
@@ -760,7 +760,7 @@ func SidebarSignIn(currentPath string) templ.Component {
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 272, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 278, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
+59 -3
View File
@@ -138,7 +138,8 @@ function pollScanProgress(
}
}
const overallProgress = Math.round(totalProgress / jobIds.length);
// status.progress arrives 0-1 from the server; render as percent.
const overallProgress = Math.round((totalProgress / jobIds.length) * 100);
const progressBar = document.getElementById(
"scan-progress-bar",
) as HTMLElement;
@@ -172,14 +173,18 @@ function updateLibraryProgress(jobId: string, status: any): void {
const bar = document.getElementById(`bar-${jobId}`) as HTMLElement;
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
// status.progress arrives 0-1 from the server; render as percent,
// otherwise the bar sits at sub-1% forever and never fills.
const percent = Math.round((status.progress || 0) * 100);
if (bar) {
bar.style.width = (status.progress || 0) + "%";
bar.style.width = percent + "%";
}
if (statusText) {
const statusMessages: Record<string, string> = {
pending: "Pending...",
running: `Scanning... ${status.progress || 0}%`,
running: `Scanning... ${percent}%`,
completed: `✓ Complete (${status.new_items || 0} items)`,
failed: `✗ Failed`,
};
@@ -187,6 +192,55 @@ function updateLibraryProgress(jobId: string, status: any): void {
}
}
// On page load: if a scan is already running (started before this page
// was opened, or the page was refreshed mid-scan), surface the progress
// widget and re-attach polling. Websocket start-events only reach pages
// that were already open when the scan began.
async function checkActiveScans(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const resp = await fetch("/api/scanner/active", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
const jobs = (data.jobs || []) as Array<{
job_id: string;
library_id?: string;
}>;
if (jobs.length === 0) return;
// Label rows by library name where we can resolve one.
const libraryNames: Record<string, string> = {};
try {
const libsResp = await fetch("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
});
if (libsResp.ok) {
const libsData = await libsResp.json();
for (const lib of libsData.data || []) {
libraryNames[lib.id] = lib.name;
}
}
} catch {
// Names fall back to ids below.
}
const jobIds = jobs.map((job) => job.job_id);
const names: Record<string, string> = {};
for (const job of jobs) {
names[job.job_id] =
(job.library_id && libraryNames[job.library_id]) ||
`Library ${(job.library_id || job.job_id).slice(0, 8)}`;
}
showScanProgress(jobIds, names);
pollScanProgress(jobIds, names);
} catch (error) {
console.error("Failed to check for active scans:", error);
}
}
function showScanResults(
libCount: number,
files: number,
@@ -389,6 +443,7 @@ document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
});
export {
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
@@ -396,6 +451,7 @@ export {
};
Alpine.data("admin", () => ({
checkActiveScans,
hideScanProgress,
loadWatchStatus,
scanAllLibraries,
+83
View File
@@ -1,6 +1,7 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
import { renderNoteHtml } from "./markdown";
import { strToU8, zipSync } from "fflate";
import {
buildAnnotationRows,
@@ -124,6 +125,14 @@ interface MetadataEditorState {
ratingText(): string;
setRating(value: number): Promise<void>;
clearRating(): Promise<void>;
hasReview: boolean;
reviewHtml: string;
reviewOpen: boolean;
reviewDraft: string;
reviewSaving: boolean;
openReviewEditor(): void;
saveReview(): Promise<void>;
deleteReview(): Promise<void>;
toggleRead(read: boolean): Promise<void>;
resolveConflict(conflictId: string, winner: string): Promise<void>;
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
@@ -157,6 +166,13 @@ Alpine.data("bookDetail", () => {
userRating: 0,
ratingHover: 0,
ratingSaving: false,
// Review state: hasReview/reviewHtml hydrate from the server-rendered
// raw markdown in #review-raw; reviewDraft is the editor's working copy.
hasReview: false,
reviewHtml: "",
reviewOpen: false,
reviewDraft: "",
reviewSaving: false,
conflictId: "",
conflictWinner: "",
readSaving: false,
@@ -467,6 +483,14 @@ Alpine.data("bookDetail", () => {
const ratingAttr = document.body.getAttribute("data-rating");
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
// Hydrate the review card from the server-rendered raw markdown.
const reviewRaw = document.getElementById("review-raw");
if (reviewRaw) {
const source = reviewRaw.textContent || "";
this.hasReview = source.trim().length > 0;
this.reviewHtml = renderNoteHtml(source);
}
const conflictId = document.body.getAttribute("data-conflict-id");
const conflictWinner = document.body.getAttribute("data-conflict-winner");
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
@@ -573,6 +597,65 @@ Alpine.data("bookDetail", () => {
}
},
openReviewEditor() {
// Seed the draft from the CURRENT rendered source: the raw markdown
// lives in #review-raw when a review exists, otherwise start empty.
const reviewRaw = document.getElementById("review-raw");
this.reviewDraft = reviewRaw ? reviewRaw.textContent || "" : "";
this.reviewOpen = true;
},
async saveReview() {
if (this.reviewSaving) return;
this.reviewSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
method: "PUT",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ review: this.reviewDraft }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to save review");
}
// Reload so the server-rendered card (or its absence) matches.
window.location.reload();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save review",
"error",
);
this.reviewSaving = false;
}
},
async deleteReview() {
if (this.reviewSaving || !this.hasReview) return;
this.reviewSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to delete review");
}
window.location.reload();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to delete review",
"error",
);
this.reviewSaving = false;
}
},
async toggleRead(read: boolean) {
if (this.readSaving) return;
this.readSaving = true;
+23
View File
@@ -41,6 +41,29 @@ const restoreLibrarySelection = (): void => {
};
const initializeScanListener = function (this: any) {
// Restore state on page load: a scan that started before this page
// was opened (or a refresh mid-scan) emits no websocket event until
// its next progress tick, so ask the server directly first.
const restoreActiveScan = async () => {
try {
const token = localStorage.getItem("token");
if (!token) return;
const resp = await fetch("/api/scanner/active-summary", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
if (data.scanning) {
this.scanning = true;
this.scanProgress = Math.round((data.progress || 0) * 100);
}
} catch {
// Offline or endpoint unavailable — websocket events still drive
// the state once they arrive.
}
};
restoreActiveScan();
const handler = (message: any) => {
switch (message.type) {
case "scan_progress":
+77 -28
View File
@@ -758,9 +758,15 @@ document.addEventListener("alpine:init", () => {
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
this.handleKeydown(ev),
);
// Pointer activity inside page iframes should keep the chrome awake
// Edge bands work inside page documents too — screenY keeps the
// check viewport-relative even when the iframe is content-sized
// (fixed-layout pages and EPUB sections are separate documents).
doc.addEventListener("pointermove", () => this.pokeChrome(), {
doc.addEventListener("pointermove", (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const vy = e.screenY - window.screenY;
if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92)
this.pokeChrome(1200);
}, {
passive: true,
});
// Tap zones inside the page document (iframe events never bubble
@@ -918,6 +924,10 @@ document.addEventListener("alpine:init", () => {
this.renderAllHighlights();
});
this.view.addEventListener("relocate", (e: any) => {
// A touch swipe page turn ends in a snap-relocate: reading
// intent — dismiss the chrome (guards keep drawers, selections,
// and pinned mode up).
if (e.detail?.reason === "snap") this.hideChromeNow();
const { fraction, location, pageItem, cfi, tocItem, section } =
e.detail;
// Only dismiss the popover on an actual position change — the
@@ -1036,12 +1046,21 @@ document.addEventListener("alpine:init", () => {
this.setupTapZones();
},
// Chrome visibility: bars overlay the edge-to-edge reading surface.
// 'auto-hide' keeps them up while the pointer is active and fades them
// out after a quiet period; 'always-visible' pins them.
// 'auto-hide' reveals them ONLY from explicit UI intent — cursor in
// the top/bottom edge bands (desktop) or the flick-down gesture
// (touch) — and every reading action (page turns, selections,
// highlight saves) dismisses or never raises them. 'always-visible'
// pins them.
setupChrome() {
document.addEventListener("pointermove", () => this.pokeChrome(), {
passive: true,
});
// Band check via screenY so it works identically for the host
// document and content iframes (iframe clientY is content-space,
// which is unrelated to the viewport in scrolled layouts).
document.addEventListener("pointermove", (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const vy = e.screenY - window.screenY;
if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92)
this.pokeChrome(1200);
}, { passive: true });
// Dismiss the selection popover and tools menu on clicks outside them
// (iframe clicks are covered by the selection tracker's collapsed
// check; the ⋯ toggle is exempt so its own click re-toggles cleanly).
@@ -1060,9 +1079,10 @@ document.addEventListener("alpine:init", () => {
this.chromeVisible = true;
return;
}
this.pokeChrome();
// Chrome starts hidden: reveal is an explicit gesture (edge band /
// flick down), never a side effect of loading.
},
pokeChrome() {
pokeChrome(hideAfterMs = 2500) {
// Never raise the chrome while a touch selection is live — the
// selection popover owns the screen until the selection is
// consumed or dismissed.
@@ -1073,7 +1093,18 @@ document.addEventListener("alpine:init", () => {
this.hideTimer = setTimeout(() => {
if (this.anyDrawerOpen() || this.chromeHasFocus()) return;
this.chromeVisible = false;
}, 2500);
}, hideAfterMs);
},
// Reading intent: dismiss the chrome immediately. Respects pinned
// mode and anything that owns the screen.
hideChromeNow() {
if (this.chromeBehavior !== "auto-hide") return;
if (this.anyDrawerOpen() || this.anySelection) return;
this.chromeVisible = false;
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
},
chromeHasFocus(): boolean {
const el = document.activeElement;
@@ -1182,7 +1213,31 @@ document.addEventListener("alpine:init", () => {
(e: PointerEvent) => {
if (e.pointerId !== downId) return;
downId = -1;
if (moved || longPressed || Date.now() - downT > 500) return;
// Flick-down/up chrome gesture (touch only): vertical-dominant
// and fast. Down reveals a hidden chrome; while visible, down
// OR up dismisses (up reads as "push it away"). Reading
// gestures never look like this: side swipes (page turns) are
// horizontal, long-press selection engages slowly, and zoomed
// pages already bail out of tap routing.
const dy = e.clientY - downY;
const dx = e.clientX - downX;
const dt = Date.now() - downT;
if (
e.pointerType !== "mouse" &&
!this.anySelection &&
dt < 400 &&
Math.abs(dy) > 48 &&
Math.abs(dy) > Math.abs(dx) * 1.8
) {
if (this.tapZoneTimer) {
clearTimeout(this.tapZoneTimer);
this.tapZoneTimer = null;
}
if (!this.chromeVisible) this.pokeChrome();
else this.hideChromeNow();
return;
}
if (moved || longPressed || dt > 500) return;
if (!this.tapZonesEnabled) return;
const target = e.target as HTMLElement | null;
if (
@@ -1235,27 +1290,17 @@ document.addEventListener("alpine:init", () => {
}
this.tapZoneTimer = setTimeout(() => {
this.tapZoneTimer = null;
// Touch pages by swipe only (the paginator's pan + snap); a tap
// anywhere just toggles the chrome. The edge zones remain for
// mouse-driven taps.
if (window.matchMedia("(pointer: coarse)").matches) {
this.toggleChromeManually();
return;
}
// Touch: taps are fully inert — paging is swipe-only and the
// chrome is flick-down/up territory (handoff item 7). A stray
// tap must never flash the UI.
if (window.matchMedia("(pointer: coarse)").matches) return;
const zone = this.tapZoneSize / 100;
if (relX < zone) this.goLeft();
else if (relX > 1 - zone) this.goRight();
else this.toggleChromeManually();
// Mouse taps in the content are content (links, selection) —
// they never raise the chrome; the edge bands own that.
}, 280);
},
toggleChromeManually() {
if (this.chromeVisible) {
this.chromeVisible = false;
if (this.hideTimer) clearTimeout(this.hideTimer);
} else {
this.pokeChrome();
}
},
applyTapZoneSettings() {
saveSettings({
tap_zones_enabled: this.tapZonesEnabled,
@@ -3194,9 +3239,12 @@ document.addEventListener("alpine:init", () => {
},
goLeft() {
this.view?.goLeft?.();
// A page turn is reading intent: dismiss the chrome.
this.hideChromeNow();
},
goRight() {
this.view?.goRight?.();
this.hideChromeNow();
},
nextPage() {
this.view?.next?.();
@@ -4076,7 +4124,8 @@ document.addEventListener("alpine:init", () => {
t?.tagName === "SELECT" ||
t?.tagName === "TEXTAREA" ||
!!t?.isContentEditable;
this.pokeChrome();
// NOTE: no chrome poke here — keyboard page turns are reading
// actions and must leave the chrome alone (handoff item 7).
if (typing && k !== "Escape") return;
if (k === "ArrowLeft" || k === "h") {
if (event.altKey) {