Merge branch 'reader-redesign': reader v2 — immersive chrome, annotations, search, touch, webtoon
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:
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.30.0
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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{}{
|
||||
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+240
-219
File diff suppressed because one or more lines are too long
+25
-2
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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: {
|
||||
|
||||
Vendored
+6
-1
@@ -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
@@ -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 ---------- */
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user