Merge branch 'reader-redesign': reader v2 — immersive chrome, annotations, search, touch, webtoon
Release / build-and-push (push) Successful in 3m0s

Full reader redesign across 22 commits (with the foliate-js fork's
zoom-control engine work pinned per release):

- Phase 0: panel/chrome stabilization, bookmarks end-to-end (REST CRUD
  via AnnotationService), dead UI removal, tombstone resurrection fix
- Phase 1: edge-to-edge glass chrome with auto-hide, slide-over drawers,
  tri-state PDF pointer mode (Smart/Pan/Text), Kindle-style theme swatches
- Phase 2: touch gesture engine (pinch/pan/swipe/double-tap), tap zones,
  mobile sheets + compact toolbar with overflow menu
- Phase 3: EPUB highlights & notes (selection popover, overlayer
  rendering, annotations drawer), PDF text highlights (fraction-rect
  overlays), in-book search for both EPUB and PDF, back-to-location
  stack, page thumbnails, shortcuts help modal, desktop edge zones
- Phase 4: webtoon (vertical-scroll) mode for comics, brightness/
  contrast/night filters, bookmark toast feedback
- Build hygiene: vite stale-chunk cleanup, browser-verified fixes for
  Alpine proxy/dpr/duplicate-key classes of bugs along the way
This commit is contained in:
2026-08-18 09:53:10 -04:00
20 changed files with 3473 additions and 688 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
package database
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
package database
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
package database
+11 -5
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
// source: queries.sql
package database
@@ -11274,7 +11274,7 @@ SET
title = $2,
notes = $3,
position = $4,
updated_at = NOW()
last_modified_at = NOW()
WHERE id = $1 AND user_id = $5
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at
`
@@ -11334,7 +11334,9 @@ UPDATE media_bookmarks SET
last_modified_at = $11,
last_modified_source = $12,
device_sync_data = $13,
created_at = created_at
created_at = created_at,
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at
`
@@ -11474,7 +11476,9 @@ UPDATE media_highlights SET
last_modified_at = $12,
last_modified_source = $13,
device_sync_data = $14,
updated_at = NOW()
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at
`
@@ -12152,7 +12156,9 @@ UPDATE media_notes SET
last_modified_at = $10,
last_modified_source = $11,
device_sync_data = $12,
updated_at = NOW()
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at
`
+10 -4
View File
@@ -802,7 +802,9 @@ UPDATE media_highlights SET
last_modified_at = $12,
last_modified_source = $13,
device_sync_data = $14,
updated_at = NOW()
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
RETURNING *;
@@ -857,7 +859,9 @@ UPDATE media_notes SET
last_modified_at = $10,
last_modified_source = $11,
device_sync_data = $12,
updated_at = NOW()
updated_at = NOW(),
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
RETURNING *;
@@ -913,7 +917,9 @@ UPDATE media_bookmarks SET
last_modified_at = $11,
last_modified_source = $12,
device_sync_data = $13,
created_at = created_at
created_at = created_at,
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
RETURNING *;
@@ -2481,7 +2487,7 @@ SET
title = $2,
notes = $3,
position = $4,
updated_at = NOW()
last_modified_at = NOW()
WHERE id = $1 AND user_id = $5
RETURNING *;
+237 -18
View File
@@ -115,20 +115,51 @@ type UpdateMediaNoteRequest struct {
// CreateMediaHighlightRequest represents the request for creating a media highlight
type CreateMediaHighlightRequest struct {
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"required,max=100"`
EndPosition string `json:"end_position" validate:"required,max=100"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteID string `json:"note_id"`
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"max=1000"`
EndPosition string `json:"end_position" validate:"max=1000"`
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteText string `json:"note_text" validate:"max=10000"`
NoteID string `json:"note_id"`
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
ChapterReference int32 `json:"chapter_reference"`
}
// UpdateMediaHighlightRequest represents the request for updating a media highlight
type UpdateMediaHighlightRequest struct {
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"required,max=100"`
EndPosition string `json:"end_position" validate:"required,max=100"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteID string `json:"note_id"`
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"max=1000"`
EndPosition string `json:"end_position" validate:"max=1000"`
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteText string `json:"note_text" validate:"max=10000"`
NoteID string `json:"note_id"`
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
ChapterReference int32 `json:"chapter_reference"`
}
// CreateMediaBookmarkRequest represents the request for creating a media bookmark
type CreateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Position string `json:"position" validate:"max=100"`
Notes string `json:"notes" validate:"max=10000"`
CfiPosition string `json:"cfi_position" validate:"max=255"`
PageNumber int32 `json:"page_number"`
ChapterNumber int32 `json:"chapter_number"`
Percentage float64 `json:"percentage"`
ChapterReference int32 `json:"chapter_reference"`
}
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
type UpdateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Notes string `json:"notes" validate:"max=10000"`
Position string `json:"position" validate:"max=100"`
}
type MediaHandler struct {
@@ -1557,14 +1588,20 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
if mh.annotationSvc != nil {
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaID,
UserID: pgUserID,
SelectionText: req.SelectionText,
StartPosition: req.StartPosition,
EndPosition: req.EndPosition,
Color: color,
Source: "web",
ModifiedAt: time.Now(),
MediaItemID: pgMediaID,
UserID: pgUserID,
SelectionText: req.SelectionText,
StartPosition: req.StartPosition,
EndPosition: req.EndPosition,
EpubcfiStart: req.EpubcfiStart,
EpubcfiEnd: req.EpubcfiEnd,
Color: color,
NoteText: req.NoteText,
PercentageStart: req.PercentageStart,
PercentageEnd: req.PercentageEnd,
ChapterReference: req.ChapterReference,
Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1637,6 +1674,42 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
color = req.Color
}
// Prefer the sync-aware path: the same selection text + CFI resolves to
// the same dedup key, so this performs an LWW update of the existing row
// (including note_text and CFI columns the plain query cannot touch).
if mh.annotationSvc != nil {
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"})
}
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: req.StartPosition,
EndPosition: req.EndPosition,
EpubcfiStart: req.EpubcfiStart,
EpubcfiEnd: req.EpubcfiEnd,
Color: color,
NoteText: req.NoteText,
PercentageStart: req.PercentageStart,
PercentageEnd: req.PercentageEnd,
ChapterReference: req.ChapterReference,
Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, result.Highlight)
}
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
SelectionText: req.SelectionText,
@@ -1677,6 +1750,152 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
// GetMediaBookmarks handles GET /api/media-items/:id/bookmarks
func (mh *MediaHandler) GetMediaBookmarks(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"})
}
bookmarks, err := mh.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
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, bookmarks)
}
// CreateMediaBookmark handles POST /api/media-items/:id/bookmarks
func (mh *MediaHandler) CreateMediaBookmark(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 CreateMediaBookmarkRequest
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()})
}
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
// to the plain query when the service isn't wired (e.g. some tests).
if mh.annotationSvc != nil {
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Title: req.Title,
Position: req.Position,
Notes: req.Notes,
PageNumber: req.PageNumber,
ChapterNumber: req.ChapterNumber,
CFIPosition: req.CfiPosition,
PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference,
Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, result.Bookmark)
}
bookmark, err := mh.db.CreateMediaBookmark(c.Request().Context(), database.CreateMediaBookmarkParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
PageNumber: pgtype.Int4{Int32: req.PageNumber, Valid: req.PageNumber > 0},
ChapterNumber: pgtype.Int4{Int32: req.ChapterNumber, Valid: req.ChapterNumber > 0},
CfiPosition: pgtype.Text{String: req.CfiPosition, Valid: req.CfiPosition != ""},
Title: req.Title,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, bookmark)
}
// UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId
func (mh *MediaHandler) UpdateMediaBookmark(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"})
}
bookmarkID := c.Param("bookmarkId")
bookmarkUUID, err := uuid.Parse(bookmarkID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
}
var req UpdateMediaBookmarkRequest
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()})
}
bookmark, err := mh.db.UpdateMediaBookmark(c.Request().Context(), database.UpdateMediaBookmarkParams{
ID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
Title: req.Title,
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
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, bookmark)
}
// DeleteMediaBookmark handles DELETE /api/media-items/:id/bookmarks/:bookmarkId
func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
bookmarkID := c.Param("bookmarkId")
bookmarkUUID, err := uuid.Parse(bookmarkID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
}
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
if mh.annotationSvc != nil {
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// SearchMediaItems handles GET /api/media-items/search
// Supports two modes:
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
+6
View File
@@ -41,6 +41,12 @@ func registerMediaRoutes(cfg *Config) {
protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight)
protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight)
// Bookmark routes (all authenticated users)
protected.GET("/media-items/:id/bookmarks", cfg.MediaHandler.GetMediaBookmarks)
protected.POST("/media-items/:id/bookmarks", cfg.MediaHandler.CreateMediaBookmark)
protected.PUT("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.UpdateMediaBookmark)
protected.DELETE("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.DeleteMediaBookmark)
// Admin-only media routes
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
+17 -12
View File
@@ -392,18 +392,23 @@ func (s *ReaderService) UpdateSettings(
func (s *ReaderService) getDefaultSettings() map[string]interface{} {
return map[string]interface{}{
"chrome_behavior": "auto-hide",
"progress_mode": "pages",
"chrome_theme": "tokyo-night",
"reading_theme": "dark",
"reading_font": "literata",
"font_size": 16,
"line_height": 1.6,
"margin_width": 20,
"tap_zone_size": 30,
"auto_scroll": false,
"panel_zoom_enabled": true,
"double_page_spread": true,
"chrome_behavior": "auto-hide",
"progress_mode": "pages",
"chrome_theme": "tokyo-night",
"reading_theme": "dark",
"reading_font": "literata",
"font_size": 16,
"line_height": 1.6,
"margin_width": 20,
"tap_zone_size": 30,
"auto_scroll": false,
"panel_zoom_enabled": true,
"double_page_spread": true,
"pdf_interaction_mode": "select",
"fx_brightness": 1,
"fx_contrast": 1,
"fx_invert": false,
"tap_zones_enabled": true,
// Dockable panel defaults
"panel_layout": map[string]interface{}{
+31 -5
View File
@@ -100,10 +100,12 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight
}
if existing.Deleted.Bool {
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
}
return s.createHighlight(ctx, req, dedupKey)
// Newer than the tombstone: a deliberate re-create. Resurrect via the
// LWW update (which clears deleted/deleted_at).
return s.applyLWW(ctx, req, existing, dedupKey)
}
return s.applyLWW(ctx, req, existing, dedupKey)
@@ -361,7 +363,11 @@ func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (
}
if existing.Deleted.Valid && existing.Deleted.Bool {
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect.
return s.applyNoteLWW(ctx, req, existing, dedupKey)
}
return s.applyNoteLWW(ctx, req, existing, dedupKey)
@@ -504,10 +510,13 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
}
if existing.Deleted.Bool {
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
}
return s.createBookmark(ctx, req, dedupKey)
// Newer than the tombstone: a deliberate re-create. Resurrect via the
// LWW update instead of INSERT (the tombstoned row still holds the
// UNIQUE(media_item_id, user_id, title) slot).
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
}
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
@@ -717,6 +726,23 @@ func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string {
return hex.EncodeToString(h.Sum(nil))
}
// incomingNewerThanTombstone reports whether an incoming save should
// resurrect a tombstoned annotation. A save carrying a modification time
// newer than the tombstone (e.g. the user deliberately re-adding on the web,
// or a device that genuinely re-created it) wins; a save with a missing or
// older timestamp is treated as a stale replay from a client that still has
// the deleted annotation, and the tombstone stands.
func incomingNewerThanTombstone(incoming time.Time, deletedAt, lastModifiedAt pgtype.Timestamptz) bool {
if incoming.IsZero() {
return false
}
tombstone := deletedAt.Time
if lastModifiedAt.Valid && lastModifiedAt.Time.After(tombstone) {
tombstone = lastModifiedAt.Time
}
return incoming.After(tombstone)
}
func normalizeText(s string) string {
fields := strings.Fields(strings.ToLower(s))
return strings.Join(fields, " ")
+28
View File
@@ -341,3 +341,31 @@ func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.M
PercentageEnd: pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0},
}
}
func TestIncomingNewerThanTombstone(t *testing.T) {
base := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
delAt := pgtype.Timestamptz{Time: base, Valid: true}
lastMod := pgtype.Timestamptz{Time: base.Add(-time.Minute), Valid: true}
tests := []struct {
name string
incoming time.Time
deleted pgtype.Timestamptz
lastMod pgtype.Timestamptz
want bool
}{
{"newer than tombstone resurrects", base.Add(time.Hour), delAt, lastMod, true},
{"older than tombstone is a stale replay", base.Add(-time.Hour), delAt, lastMod, false},
{"missing timestamp never resurrects", time.Time{}, delAt, lastMod, false},
{"exactly equal does not resurrect", base, delAt, lastMod, false},
{"last_modified newer than deleted_at wins", base.Add(30 * time.Minute), delAt, pgtype.Timestamptz{Time: base.Add(90 * time.Minute), Valid: true}, false},
{"invalid timestamps compare against deleted_at", base.Add(time.Hour), delAt, pgtype.Timestamptz{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := incomingNewerThanTombstone(tt.incoming, tt.deleted, tt.lastMod); got != tt.want {
t.Errorf("incomingNewerThanTombstone() = %v, want %v", got, tt.want)
}
})
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"dev": "npm run build:ts:dev && npm run build:css"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#d4d87a9",
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#e448d36",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"highlight.js": "^11.11.1",
+845 -328
View File
File diff suppressed because it is too large Load Diff
+240 -219
View File
File diff suppressed because one or more lines are too long
+25 -2
View File
@@ -1,5 +1,5 @@
import { defineConfig } from "vite";
import { cpSync, mkdirSync } from "node:fs";
import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
const pdfjsAssets = () => ({
@@ -16,6 +16,29 @@ const pdfjsAssets = () => ({
},
});
// emptyOutDir must stay false (web/static also holds tracked assets),
// so hashed chunks from previous builds would otherwise accumulate
// forever and leak into Docker images via the build context. Remove
// any *-<hash>.js(.map) that this build did not produce.
const cleanStaleChunks = () => {
const produced = new Set<string>();
return {
name: "clean-stale-chunks",
generateBundle(_options, bundle) {
for (const fileName of Object.keys(bundle)) produced.add(fileName);
},
closeBundle() {
const outDir = "web/static";
const chunkRe = /-[A-Za-z0-9_-]{8}\.js(\.map)?$/;
for (const f of readdirSync(outDir)) {
if (chunkRe.test(f) && !produced.has(f)) {
rmSync(join(outDir, f));
}
}
},
};
};
export default defineConfig({
resolve: {
alias: {
@@ -24,7 +47,7 @@ export default defineConfig({
},
},
base: "/static/",
plugins: [pdfjsAssets()],
plugins: [pdfjsAssets(), cleanStaleChunks()],
build: {
outDir: "web/static",
emptyOutDir: false,
+163
View File
@@ -0,0 +1,163 @@
// PDF in-book search: text extraction with item geometry, and a matcher
// that maps hits back to page-fraction rects for the overlay renderer.
//
// pdf.js text items carry positional data (transform/width/height in PDF
// units at scale 1) but their strings often omit inter-word spaces — gaps
// are positional. Pages are therefore joined gap-aware, with a char→item
// map so each match can be covered by the rects of the items it spans.
export interface PdfSearchItem {
/** page-fraction rect of this text item */
x: number;
y: number;
w: number;
h: number;
/** char offset of this item's text within the page string */
start: number;
length: number;
}
export interface PdfPageText {
index: number;
/** normalized, gap-joined page text (lowercased by the matcher) */
text: string;
items: PdfSearchItem[];
}
export interface PdfSearchHit {
page: number;
rects: number[][];
pre: string;
match: string;
post: string;
}
interface RawItem {
str: string;
transform: number[];
width: number;
height: number;
hasEOL: boolean;
}
/** Join one page's text items into a searchable string + item map. */
export function buildPageText(
rawItems: RawItem[],
viewportWidth: number,
viewportHeight: number,
index: number,
): PdfPageText {
const vw = viewportWidth || 1;
const vh = viewportHeight || 1;
let text = "";
const items: PdfSearchItem[] = [];
let prevRight: number | null = null;
let prevBaseline: number | null = null;
for (const item of rawItems) {
if (!item.str) continue;
const t = item.transform ?? [1, 0, 0, 1, 0, 0];
const baseline = t[5] ?? 0;
const x = t[4] ?? 0;
const size =
Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10;
const w = Math.abs(item.width) || 0;
const h = size;
let sep = "";
if (text && !text.endsWith(" ") && prevRight != null) {
const newLine =
item.hasEOL ||
prevBaseline == null ||
Math.abs(baseline - prevBaseline) > size * 0.5;
const gap = x - prevRight;
if (newLine || gap > size * 0.2) sep = " ";
}
const s = item.str.replace(/\s+/g, " ");
const start = text.length + sep.length;
text += sep + s;
items.push({
x: x / vw,
y: (vh - baseline - h) / vh,
w: w / vw,
h: h / vh,
start,
length: s.length,
});
prevRight = x + w;
prevBaseline = baseline;
}
return { index, text: text.trimStart(), items };
}
/** Extract all pages of a PDF via pdf.js, reporting progress 0..1. */
export async function extractPdfPages(
pdf: any,
onProgress?: (fraction: number) => void,
): Promise<PdfPageText[]> {
const pages: PdfPageText[] = [];
const num = pdf.numPages as number;
for (let i = 0; i < num; i++) {
const page = await pdf.getPage(i + 1);
const viewport = page.getViewport({ scale: 1 });
const tc = await page.getTextContent();
pages.push(
buildPageText(tc.items, viewport.width, viewport.height, i),
);
onProgress?.((i + 1) / num);
}
return pages;
}
const CONTEXT = 60;
/**
* Case-insensitive search over extracted pages. Returns hits grouped in
* page order; each hit carries the page-fraction rects of the items it
* spans (capped to keep pathological fills cheap) plus a trimmed excerpt.
*/
export function searchPdfPages(
pages: PdfPageText[],
query: string,
locales = "en",
): PdfSearchHit[] {
const needle = query.toLocaleLowerCase(locales).replace(/\s+/g, " ").trim();
if (!needle) return [];
const hits: PdfSearchHit[] = [];
for (const page of pages) {
const haystack = page.text.toLocaleLowerCase(locales);
let from = 0;
for (;;) {
const s = haystack.indexOf(needle, from);
if (s === -1) break;
const e = s + needle.length;
from = s + Math.max(1, needle.length);
const rects: number[][] = [];
for (const it of page.items) {
if (it.length <= 0) continue;
if (it.start + it.length <= s || it.start >= e) continue;
if (rects.length >= 12) break;
rects.push([it.x, it.y, it.w, it.h]);
}
if (!rects.length) continue;
const pre = page.text.slice(Math.max(0, s - CONTEXT), s);
const post = page.text.slice(e, e + CONTEXT);
hits.push({
page: page.index,
rects,
pre: (s > CONTEXT ? "…" : "") + pre.trimStart(),
match: page.text.slice(s, e),
post: post.trimEnd() + (page.text.length > e + CONTEXT ? "…" : ""),
});
}
}
return hits;
}
+1337 -72
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -79,6 +79,11 @@ export function getDefaultSettings(): ReaderSettings {
line_height: 1.6,
margin_width: 20,
double_page_spread: true,
pdf_interaction_mode: "select",
fx_brightness: 1,
fx_contrast: 1,
fx_invert: false,
tap_zones_enabled: true,
reading_direction: "ltr",
hardware_acceleration: true,
panel_layout: {
+6 -1
View File
@@ -189,7 +189,7 @@ interface ReaderSettings {
progress_mode: "pages" | "chapter" | "percentage" | "time-left";
chrome_theme: string;
reading_theme: "light" | "sepia" | "dark" | "night" | "high-contrast";
reading_theme: string;
reading_font:
| "literata"
@@ -208,6 +208,11 @@ interface ReaderSettings {
auto_scroll: boolean;
double_page_spread: boolean;
pdf_interaction_mode: "select" | "pan" | "text";
fx_brightness: number;
fx_contrast: number;
fx_invert: boolean;
tap_zones_enabled: boolean;
reading_direction: "ltr" | "rtl" | "vertical";
reading_mode: "dark" | "light";
+507 -17
View File
@@ -725,7 +725,7 @@
border: 1px solid var(--wood-border);
}
/* ---------- Reader ---------- */
/* ---------- Reader chrome & drawers ---------- */
.reader-icon {
display: block;
fill: none;
@@ -735,31 +735,521 @@
stroke-linejoin: round;
}
.dockable-panel {
/* Glass chrome: translucent theme-tinted bars over the edge-to-edge
reading surface. Blur + saturate the content behind, hairline border,
soft directional shadow. */
.reader-glass {
background-color: color-mix(in srgb, var(--bg-primary) 70%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(1.4);
backdrop-filter: blur(18px) saturate(1.4);
border-color: color-mix(in srgb, var(--border) 60%, transparent);
}
#reader-topbar.reader-glass {
border-bottom: 1px solid
color-mix(in srgb, var(--border) 60%, transparent);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.16);
}
#reader-bottombar.reader-glass {
border-top: 1px solid
color-mix(in srgb, var(--border) 60%, transparent);
box-shadow: 0 -4px 18px rgba(0, 0, 0, 0.16);
}
/* Bars slide away when the chrome hides (opacity handled on #reader-chrome) */
#reader-topbar,
#reader-bottombar {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
#reader-chrome.chrome-hidden #reader-topbar {
transform: translateY(-101%);
}
#reader-chrome.chrome-hidden #reader-bottombar {
transform: translateY(101%);
}
/* Theme-aware translucent hover pills + focus rings for bar controls */
#reader-topbar button:hover,
#reader-bottombar button:hover,
#reader-topbar a:hover {
background-color: color-mix(in srgb, currentColor 13%, transparent);
}
#reader-topbar button:focus-visible,
#reader-bottombar button:focus-visible,
#reader-topbar a:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.reader-sep {
background-color: color-mix(in srgb, var(--border) 80%, transparent);
}
.reader-select {
background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
color: inherit;
}
.reader-select:hover {
border-color: #3b82f6;
}
/* Progress slider: thin rounded track + floating white thumb */
#reader-bottombar input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 18px;
background: transparent;
cursor: pointer;
accent-color: #3b82f6;
}
#reader-bottombar input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
border-radius: 9999px;
background: color-mix(in srgb, currentColor 22%, transparent);
}
#reader-bottombar input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
margin-top: -5px;
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
transition: transform 0.12s ease;
}
#reader-bottombar input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
#reader-bottombar input[type="range"]::-moz-range-track {
height: 4px;
border-radius: 9999px;
background: color-mix(in srgb, currentColor 22%, transparent);
}
#reader-bottombar input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
}
.drawer-scrim {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.45);
z-index: 45;
}
.reader-drawer {
position: fixed;
top: 0;
bottom: 0;
width: 340px;
max-width: calc(100vw - 2rem);
background-color: var(--bg-secondary);
z-index: 50;
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
.reader-drawer.left {
left: 0;
border-right: 1px solid var(--border);
}
.reader-drawer.right {
right: 0;
border-left: 1px solid var(--border);
}
/* Mobile: drawers become full-width sheets */
@media (max-width: 640px) {
.reader-drawer {
width: 100%;
max-width: 100%;
}
}
.reader-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
transition: max-height 0.2s ease-out;
flex-shrink: 0;
}
.reader-drawer-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.reader-seg {
display: inline-flex;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
background-color: color-mix(in srgb, var(--bg-primary) 50%, transparent);
border-radius: 0.5rem;
overflow: hidden;
}
.dockable-panel.panel-collapsed .panel-content {
display: none;
.reader-seg button {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
line-height: 1.25rem;
}
.panel-header {
user-select: none;
.reader-seg button.active {
background-color: #2563eb;
color: #ffffff;
}
.panel-header:hover {
background-color: var(--surface-hover);
.theme-swatch {
height: 2.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
}
.panel-container {
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
width: 320px;
max-height: calc(100vh - 8rem);
.theme-swatch:hover {
border-color: #3b82f6;
}
/* Selection popover & annotations drawer widgets */
.reader-popover {
position: fixed;
transform: translate(-50%, calc(-100% - 10px));
background-color: color-mix(in srgb, var(--bg-primary) 85%, transparent);
-webkit-backdrop-filter: blur(16px) saturate(1.3);
backdrop-filter: blur(16px) saturate(1.3);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: 0.75rem;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
padding: 0.5rem 0.625rem;
z-index: 60;
}
.color-dot {
width: 1.375rem;
height: 1.375rem;
border-radius: 9999px;
border: 2px solid rgba(0, 0, 0, 0.25);
transition: transform 0.12s ease;
}
.color-dot:hover {
transform: scale(1.15);
}
.color-dot.selected {
border-color: #ffffff;
box-shadow: 0 0 0 2px #3b82f6;
}
.reader-popover-btn {
padding: 0.3rem;
border-radius: 0.375rem;
color: inherit;
}
.reader-popover-btn:hover {
background-color: color-mix(in srgb, currentColor 13%, transparent);
}
.reader-popover-btn.danger:hover {
background-color: rgba(153, 27, 27, 0.6);
}
.reader-note-input {
width: 100%;
resize: vertical;
padding: 0.5rem;
border-radius: 0.5rem;
font-size: 0.875rem;
color: inherit;
background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
.reader-note-input:focus {
outline: 2px solid #3b82f6;
outline-offset: 1px;
}
.reader-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.reader-tabs button {
flex: 1;
padding: 0.5rem 0.25rem;
font-size: 0.8125rem;
color: var(--text-secondary);
border-bottom: 2px solid transparent;
}
.reader-tabs button.active {
color: var(--text-primary);
border-bottom-color: #3b82f6;
}
.reader-tab-count {
display: inline-block;
min-width: 1.25rem;
margin-left: 0.25rem;
padding: 0 0.25rem;
border-radius: 9999px;
font-size: 0.6875rem;
line-height: 1.1rem;
background-color: color-mix(in srgb, currentColor 13%, transparent);
}
.reader-hl-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Fixed-layout toolbar responsiveness is handled with Tailwind responsive
utilities in reader.templ (hidden md:flex for the full toolbar,
flex md:hidden for the compact row) — custom layer rules here would
lose the cascade to the flex utility anyway. */
.reader-tools-popover {
position: absolute;
bottom: 100%;
right: 0.5rem;
margin-bottom: 0.5rem;
min-width: 16rem;
max-width: calc(100vw - 1.5rem);
max-height: min(26rem, 65vh);
overflow-y: auto;
background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(1.3);
backdrop-filter: blur(18px) saturate(1.3);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: 0.75rem;
box-shadow: 0 -6px 28px rgba(0, 0, 0, 0.35);
padding: 0.25rem;
}
.panel-container[data-side="right"] {
border-right: none;
border-left: 1px solid var(--border);
.reader-tools-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 0.5rem;
}
.reader-tools-row + .reader-tools-row {
border-top: 1px solid
color-mix(in srgb, var(--border) 45%, transparent);
}
.reader-tools-row:hover {
background-color: color-mix(in srgb, currentColor 6%, transparent);
}
/* Fixed-layout display filters: one var drives the iframe ::part(filter)
(comics/PDFs via foliate-view exportparts) and the webtoon page images. */
#reader-view::part(filter) {
filter: var(--fx-filter, none);
}
/* Page thumbnails grid (contents drawer, fixed-layout) */
.reader-thumb-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.reader-thumb {
position: relative;
border-radius: 0.375rem;
overflow: hidden;
border: 2px solid transparent;
padding: 0;
background-color: color-mix(in srgb, currentColor 6%, transparent);
}
.reader-thumb:hover {
border-color: color-mix(in srgb, currentColor 30%, transparent);
}
.reader-thumb.active {
border-color: #3b82f6;
}
.reader-thumb-img {
aspect-ratio: 3 / 4;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.reader-thumb-img canvas,
.reader-thumb-img img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.reader-thumb-num {
position: absolute;
bottom: 0.2rem;
right: 0.35rem;
font-size: 0.65rem;
line-height: 1;
padding: 0.15rem 0.3rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.55);
color: #ffffff;
}
/* Desktop edge page-turn zones: only on hover-capable fine-pointer
devices (touch uses tap zones instead). Arrow + subtle edge gradient
appear on hover. */
.reader-edge-zone {
position: absolute;
top: 0;
bottom: 0;
width: 8%;
max-width: 72px;
min-width: 44px;
z-index: 10;
display: none;
align-items: center;
cursor: pointer;
}
.reader-edge-zone.left {
left: 0;
justify-content: flex-start;
padding-left: 0.75rem;
}
.reader-edge-zone.right {
right: 0;
justify-content: flex-end;
padding-right: 0.75rem;
}
.reader-edge-zone::before {
content: "";
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
.reader-edge-zone.left::before {
background: linear-gradient(to right, rgba(255, 255, 255, 0.08), transparent);
}
.reader-edge-zone.right::before {
background: linear-gradient(to left, rgba(255, 255, 255, 0.08), transparent);
}
.reader-edge-zone .reader-icon {
color: #ffffff;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
opacity: 0;
transition: opacity 0.15s ease;
}
.reader-edge-zone:hover::before {
opacity: 1;
}
.reader-edge-zone:hover .reader-icon {
opacity: 0.85;
}
@media (hover: hover) and (pointer: fine) {
.reader-edge-zone {
display: flex;
}
}
/* Shortcuts help modal */
.reader-help-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 70;
}
.reader-help {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(34rem, calc(100vw - 2rem));
max-height: min(38rem, calc(100vh - 4rem));
display: flex;
flex-direction: column;
background-color: color-mix(in srgb, var(--bg-primary) 90%, transparent);
-webkit-backdrop-filter: blur(20px) saturate(1.3);
backdrop-filter: blur(20px) saturate(1.3);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: 0.875rem;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
z-index: 71;
}
.reader-help-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.reader-help-body {
overflow-y: auto;
padding: 1rem 1.25rem;
}
.reader-help-section {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
margin: 1.1rem 0 0.4rem;
}
.reader-help-body .reader-help-section:first-child {
margin-top: 0;
}
.help-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.help-row > span:first-child {
color: var(--text-primary);
}
.help-keys {
text-align: right;
color: var(--text-secondary);
font-size: 0.8rem;
white-space: nowrap;
}
.help-note {
color: var(--text-secondary);
font-size: 0.75rem;
}
.kbd {
display: inline-block;
min-width: 1.4rem;
text-align: center;
padding: 0.05rem 0.4rem;
margin: 0 0.1rem;
border-radius: 0.3rem;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-bottom-width: 2px;
background-color: color-mix(in srgb, currentColor 8%, transparent);
font-family: ui-monospace, monospace;
font-size: 0.72rem;
line-height: 1.2rem;
}
/* In-book search */
.reader-search-bar {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.reader-search-status {
padding: 0.375rem 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
flex-shrink: 0;
min-height: 1.75rem;
}
.search-result {
display: block;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
line-height: 1.35;
}
.search-result:hover {
background-color: color-mix(in srgb, currentColor 9%, transparent);
}
.search-result mark {
background-color: rgba(255, 213, 79, 0.4);
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
/* ---------- Series stacked covers ---------- */
+1 -1
View File
File diff suppressed because one or more lines are too long