feat(audiobooks): A2 — narrator metadata, audio-aware UX, player polish, bookmarks
A2a (metadata round): narrators TEXT[] + narrators_search (tags pattern: accumulator + GIN pairs) + abridged bool on media_items; scanner fills from the Libation/ABS sidecar (narrators verbatim, abridged flag) with audio-payload promotion; editorial fields ride the real columns while audio_metadata stays scan-derived (duration/ codec/chapters). Detail/edit/save paths carry the fields; the web edit dialog grows an Audio section (Narrator(s) comma-input, Abridged toggle, scanned duration read-only); search weights + narrator filter (word_similarity over narrators_search) + narrator autocomplete added. A2c (per-media-type wording): copy helpers keyed on format_group — Listen Now, Mark Complete/Not Complete, Listening Progress, Your Listening Stats, Time Listening, Time listened (pages tile swapped for audio), Position H:MM:SS / total H:MM:SS on detail + progress pages (duration parsed from audio_metadata). Dashboard: system collections swap to Continue Listening / Recently Listened at DISPLAY time when the audiobooks library is selected (stored names unchanged; restores keep working); week card = Listening Time / Sessions / Books Touched; cover-corner badges identify audiobook (headphones) / comic / manga cards on mixed grids — books stay unbadged; hover action icon follows the media type. A2b (player): the scrubber binds value to the playhead with an explicit scrub guard (the handle previously froze at its initial max); header row = reader-style back arrow + Chapter n/total launcher; chapters open in a floating top-down drawer (current chapter highlighted, page never shifts); Skip/Speed rows share one label-left layout; honest wall-clock session accounting (playing-only deltas, paused flush). A2d (bookmarks): note-bearing time bookmarks ride the existing media_bookmarks substrate (notes column + sync-aware service already there) — audio locator convention audio:<seconds>, chapter_number filled, auto-title '<chapter> — H:MM:SS', capture popover with optional note, drawer with jump/edit-note/delete. Zero server changes needed. Verified live on the Libation sample: narrators backfilled by rescan; PUT edit persisted + overrides detected (narrators, abridged); force rescan preserved the user edit; reset-to-scanned-of-defaults restored truth; narrator filter/autocomplete scoped by library; dashboard listsening wording + badges render; bookmark save with note deduped to one row.
This commit is contained in:
1 parent
902cbe5915
commit
86f0af632e
38 files changed
+3069
-1552
No files matched your search
@@ -146,6 +146,8 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
series VARCHAR(255),
|
||||
series_number INTEGER,
|
||||
tags TEXT[],
|
||||
narrators TEXT[], -- Audiobook narrators (sidecar first; user-editable)
|
||||
abridged BOOLEAN NOT NULL DEFAULT FALSE, -- Libation/Audible abridged flag
|
||||
asin VARCHAR(20), -- Still relevant for ebooks
|
||||
date_published DATE,
|
||||
publisher VARCHAR(255),
|
||||
@@ -255,6 +257,13 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||
|
||||
-- Audiobook editorial facts (A2a): narrators are user-editable first-class
|
||||
-- (search/filter/badge surface); abridged rides along; audio_metadata keeps
|
||||
-- the scan-derived tech record (duration/codec/chapters).
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators_search TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS abridged BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Per-field user-override tracking (idempotent backfill for existing
|
||||
-- installs; the column is also declared in the media_items table
|
||||
-- definition above for fresh databases)
|
||||
@@ -268,6 +277,8 @@ CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at)
|
||||
-- Create GIN indexes for fast search field searches
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_narrators ON media_items USING GIN (narrators);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_narrators_search ON media_items USING GIN (narrators_search);
|
||||
|
||||
-- Drop existing triggers if exists. Both must be dropped BEFORE the function:
|
||||
-- dropping a function that triggers still reference fails (SQLSTATE 2BP01),
|
||||
|
||||
@@ -245,6 +245,8 @@ type MediaItems struct {
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
|
||||
Tags []string `db:"tags" json:"tags"`
|
||||
Narrators []string `db:"narrators" json:"narrators"`
|
||||
Abridged bool `db:"abridged" json:"abridged"`
|
||||
Asin pgtype.Text `db:"asin" json:"asin"`
|
||||
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
||||
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||
@@ -315,6 +317,7 @@ type MediaItems struct {
|
||||
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
|
||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
||||
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
|
||||
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
||||
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
||||
|
||||
@@ -503,6 +503,7 @@ type Querier interface {
|
||||
// Search Media Items queries
|
||||
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
|
||||
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
|
||||
SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
|
||||
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
|
||||
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
|
||||
// Library Visibility queries
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -149,8 +149,8 @@ GROUP BY l.id;
|
||||
|
||||
-- Media Items queries
|
||||
-- name: CreateMediaItem :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45)
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
|
||||
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
@@ -159,8 +159,8 @@ RETURNING *;
|
||||
-- (library_id, file_path) this inserts NOTHING and returns no row
|
||||
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
-- and a duplicate flag instead of pretending it created something.
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45)
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
|
||||
ON CONFLICT (library_id, file_path) DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
@@ -314,6 +314,9 @@ UPDATE media_items SET
|
||||
summary = $37,
|
||||
metadata_overrides = $38,
|
||||
audio_metadata = $39,
|
||||
narrators = $40,
|
||||
narrators_search = $41,
|
||||
abridged = $42,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
@@ -649,6 +652,7 @@ SELECT
|
||||
mi.kobo_metadata,
|
||||
mi.tags_search,
|
||||
mi.contributors_search,
|
||||
mi.narrators,
|
||||
mi.file_sha256,
|
||||
mi.opf_identifier,
|
||||
mi.opf_uuid,
|
||||
@@ -679,6 +683,11 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
||||
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
||||
))
|
||||
-- Narrator filter (A2a — arrays use the *_search column)
|
||||
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
||||
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
||||
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
||||
))
|
||||
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
||||
AND (
|
||||
sqlc.narg('year_min') = 0 OR
|
||||
@@ -713,6 +722,11 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
|
||||
WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3
|
||||
LIMIT 1
|
||||
) OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
||||
WHERE word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
||||
LIMIT 1
|
||||
)
|
||||
) OR
|
||||
-- Exact search (with quotes)
|
||||
@@ -730,7 +744,8 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
COALESCE(mi.author, '') = sqlc.narg('search_query') OR
|
||||
COALESCE(mi.series, '') = sqlc.narg('search_query') OR
|
||||
sqlc.narg('search_query') = ANY(mi.tags_search) OR
|
||||
sqlc.narg('search_query') = ANY(mi.contributors_search)
|
||||
sqlc.narg('search_query') = ANY(mi.contributors_search) OR
|
||||
sqlc.narg('search_query') = ANY(mi.narrators_search)
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
@@ -844,6 +859,23 @@ GROUP BY tag, word_similarity(sqlc.narg('search_query'), tag)::float8
|
||||
ORDER BY word_similarity(sqlc.narg('search_query'), tag)::float8 DESC, COUNT DESC
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: SearchNarratorValues :many
|
||||
SELECT
|
||||
narrator::TEXT as value,
|
||||
COUNT(*) as count,
|
||||
word_similarity(sqlc.narg('search_query'), narrator)::float8 as score
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND narrator IS NOT NULL
|
||||
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
||||
GROUP BY narrator, word_similarity(sqlc.narg('search_query'), narrator)::float8
|
||||
ORDER BY word_similarity(sqlc.narg('search_query'), narrator)::float8 DESC, COUNT DESC
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: SearchSeriesValues :many
|
||||
SELECT
|
||||
mi.series as value,
|
||||
@@ -2367,7 +2399,7 @@ DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
|
||||
|
||||
-- Get collection items
|
||||
-- name: GetCollectionItems :many
|
||||
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
|
||||
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
|
||||
FROM collection_items ci
|
||||
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
|
||||
@@ -75,6 +75,8 @@ type BookInfo struct {
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
HasConflict bool `json:"has_conflict"`
|
||||
// Media-type identifier for card badges/hover icons (A2c)
|
||||
FormatGroup string `json:"format_group"`
|
||||
}
|
||||
|
||||
type SectionData struct {
|
||||
@@ -283,6 +285,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
FormatGroup: item.FormatGroup,
|
||||
})
|
||||
}
|
||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||
@@ -333,6 +336,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
FormatGroup: item.FormatGroup,
|
||||
}
|
||||
}
|
||||
bookList = bookCards
|
||||
@@ -349,6 +353,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
|
||||
FormatGroup: book.FormatGroup,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,18 +392,18 @@ func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaIte
|
||||
}
|
||||
|
||||
params := database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: existing.SelectionText,
|
||||
StartPosition: existing.StartPosition,
|
||||
EndPosition: existing.EndPosition,
|
||||
Color: existing.Color,
|
||||
NoteText: existing.NoteText,
|
||||
PercentageStart: existing.PercentageStart,
|
||||
PercentageEnd: existing.PercentageEnd,
|
||||
EpubcfiStart: existing.EpubcfiStart,
|
||||
EpubcfiEnd: existing.EpubcfiEnd,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
ID: existing.ID,
|
||||
SelectionText: existing.SelectionText,
|
||||
StartPosition: existing.StartPosition,
|
||||
EndPosition: existing.EndPosition,
|
||||
Color: existing.Color,
|
||||
NoteText: existing.NoteText,
|
||||
PercentageStart: existing.PercentageStart,
|
||||
PercentageEnd: existing.PercentageEnd,
|
||||
EpubcfiStart: existing.EpubcfiStart,
|
||||
EpubcfiEnd: existing.EpubcfiEnd,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
@@ -439,17 +439,17 @@ func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItem
|
||||
}
|
||||
|
||||
params := database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: existing.PageNumber,
|
||||
ChapterNumber: existing.ChapterNumber,
|
||||
CfiPosition: existing.CfiPosition,
|
||||
Title: existing.Title,
|
||||
Position: existing.Position,
|
||||
Notes: existing.Notes,
|
||||
ID: existing.ID,
|
||||
PageNumber: existing.PageNumber,
|
||||
ChapterNumber: existing.ChapterNumber,
|
||||
CfiPosition: existing.CfiPosition,
|
||||
Title: existing.Title,
|
||||
Position: existing.Position,
|
||||
Notes: existing.Notes,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
UserID: userID,
|
||||
|
||||
@@ -64,7 +64,17 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
|
||||
}
|
||||
|
||||
sectionData := BuildSections(sections, libraryID)
|
||||
// Per-type wording: system-collection display swaps on audiobook scope.
|
||||
libraryType := ""
|
||||
if libraryID != "" {
|
||||
if pgLibID, perr := uuid.Parse(libraryID); perr == nil {
|
||||
if lib, lerr := h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: pgLibID, Valid: true}); lerr == nil {
|
||||
libraryType = lib.TypeName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sectionData := BuildSections(sections, libraryID, libraryType)
|
||||
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||
@@ -168,10 +178,27 @@ func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
|
||||
}
|
||||
|
||||
func BuildSections(sections []services.DashboardSection, currentLibraryID string) []SectionData {
|
||||
// BuildSections maps dashboard service sections to card data. When the
|
||||
// active library is audiobooks, system-collection titles/descriptions
|
||||
// swap to the listening wording at display time — the stored names stay
|
||||
// generic so restores and settings keep working across library types.
|
||||
func BuildSections(sections []services.DashboardSection, currentLibraryID, libraryType string) []SectionData {
|
||||
var result []SectionData
|
||||
|
||||
audioScope := libraryType == "audiobooks"
|
||||
|
||||
for _, ds := range sections {
|
||||
if audioScope && ds.IsSystem {
|
||||
switch ds.Title {
|
||||
case "Continue Reading":
|
||||
ds.Title = "Continue Listening"
|
||||
ds.Description = "Audiobooks you're currently listening to (0 < progress < 1)"
|
||||
case "Recently Read":
|
||||
ds.Title = "Recently Listened"
|
||||
ds.Description = "Audiobooks you've finished (progress >= 1)"
|
||||
}
|
||||
}
|
||||
|
||||
bookCards := make([]BookInfo, len(ds.Items))
|
||||
for i, item := range ds.Items {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
@@ -180,6 +207,7 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
FormatGroup: item.FormatGroup,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ type CreateMediaItemRequest struct {
|
||||
AlternateInfo string `json:"alternate_info"` // JSON string
|
||||
ScanInformation string `json:"scan_information"`
|
||||
Summary string `json:"summary"`
|
||||
// Audio editorial facts (A2a)
|
||||
Narrators []string `json:"narrators"`
|
||||
Abridged bool `json:"abridged"`
|
||||
}
|
||||
|
||||
// UpdateMediaItemRequest represents the request for updating a media item
|
||||
@@ -98,6 +101,10 @@ type UpdateMediaItemRequest struct {
|
||||
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
|
||||
ScanInformation string `form:"scan_information" json:"scan_information"`
|
||||
Summary string `form:"summary" json:"summary"`
|
||||
// Audiobook editorial facts (A2a): narrators verbatim display order;
|
||||
// abridged as the Libation/Audible flag.
|
||||
Narrators []string `form:"narrators" json:"narrators"`
|
||||
Abridged bool `form:"abridged" json:"abridged"`
|
||||
}
|
||||
|
||||
// CreateMediaNoteRequest represents the request for creating a media note
|
||||
@@ -897,6 +904,8 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
||||
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||
"format_group": item.FormatGroup,
|
||||
"format_mimetype": textToString(item.FormatMimetype),
|
||||
"narrators": item.Narrators,
|
||||
"abridged": item.Abridged,
|
||||
// NEW: Reading direction fields
|
||||
"manga_type": textToString(item.MangaType),
|
||||
"reading_direction": textToString(item.ReadingDirection),
|
||||
@@ -1405,6 +1414,7 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
|
||||
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
||||
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
||||
narratorsSearch := utils.NormalizeNarratorsSearch(req.Narrators)
|
||||
|
||||
// Validate and normalize ISBN
|
||||
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
||||
@@ -1454,6 +1464,9 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: req.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Narrators: req.Narrators,
|
||||
NarratorsSearch: narratorsSearch,
|
||||
Abridged: req.Abridged,
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
@@ -1519,6 +1532,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
|
||||
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
||||
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
||||
narratorsSearch := utils.NormalizeNarratorsSearch(req.Narrators)
|
||||
|
||||
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
||||
if err != nil && req.ISBN != "" {
|
||||
@@ -1572,6 +1586,9 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: req.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Narrators: req.Narrators,
|
||||
NarratorsSearch: narratorsSearch,
|
||||
Abridged: req.Abridged,
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
@@ -2427,6 +2444,9 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
if tags := c.QueryParam("tags"); tags != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
|
||||
}
|
||||
if narrator := c.QueryParam("narrator"); narrator != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
|
||||
}
|
||||
if series := c.QueryParam("series"); series != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
|
||||
}
|
||||
@@ -2468,6 +2488,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
genreFilter := c.QueryParam("genre_filter")
|
||||
publisherFilter := c.QueryParam("publisher_filter")
|
||||
tagsFilter := c.QueryParam("tags_filter")
|
||||
narratorFilter := c.QueryParam("narrator_filter")
|
||||
languageFilter := c.QueryParam("language_filter")
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
||||
@@ -2494,6 +2515,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
GenreFilter: genreFilter,
|
||||
PublisherFilter: publisherFilter,
|
||||
TagsFilter: tagsFilter,
|
||||
NarratorFilter: narratorFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package handlers
|
||||
|
||||
import "bookhoard/internal/database"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
)
|
||||
|
||||
// MediaDetail embeds database.MediaItems for complete book metadata
|
||||
// No field duplication - template gets direct access to all database fields
|
||||
type MediaDetail struct {
|
||||
database.MediaItems // Embedded - ALL book fields available
|
||||
|
||||
// Audio editorial/tech surfaces (A2a): duration parsed out of the
|
||||
// audio_metadata JSONB the scanner wrote (display-only).
|
||||
audioDurationSeconds float64
|
||||
audioParsed bool
|
||||
|
||||
// Absolute on-disk location (library folder + relative path), resolved by
|
||||
// the page handler so the UI can show where the file lives.
|
||||
FileLocation string `json:"file_location"`
|
||||
@@ -37,6 +47,7 @@ type MediaDetail struct {
|
||||
// BookStatsData is the per-book reading-stats card payload.
|
||||
type BookStatsData struct {
|
||||
TotalMinutes int
|
||||
TotalSeconds int // exact wall-clock; audio "Time listened" displays from this
|
||||
TotalPages int
|
||||
SessionCount int
|
||||
MaxProgress float64 // 0..1
|
||||
@@ -44,3 +55,140 @@ type BookStatsData struct {
|
||||
LastSession string
|
||||
FirstSession string
|
||||
}
|
||||
|
||||
// IsAudio reports whether the item's format group is the dedicated
|
||||
// audiobook group (drives every "reading vs listening" wording swap).
|
||||
func (md *MediaDetail) IsAudio() bool {
|
||||
return md.FormatGroup == "audiobook"
|
||||
}
|
||||
|
||||
// --- audio-aware copy helpers (A2c): one home per verb so web + future
|
||||
// clients agree; format_group chooses the string ---
|
||||
|
||||
func (md *MediaDetail) ActionLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Listen Now"
|
||||
}
|
||||
return "Read Now"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) ActionIcon() string {
|
||||
if md.IsAudio() {
|
||||
return "headphones"
|
||||
}
|
||||
return "book-open"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) MarkCompleteLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Mark Complete"
|
||||
}
|
||||
return "Mark Read"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) MarkResetLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Mark Not Complete"
|
||||
}
|
||||
return "Mark Unread"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) ProgressHeader() string {
|
||||
if md.IsAudio() {
|
||||
return "Listening Progress"
|
||||
}
|
||||
return "Reading Progress"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) LastReadLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Last Listen"
|
||||
}
|
||||
return "Last Read"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) StatsHeader() string {
|
||||
if md.IsAudio() {
|
||||
return "Your Listening Stats"
|
||||
}
|
||||
return "Your Reading Stats"
|
||||
}
|
||||
|
||||
func (md *MediaDetail) TimeStatLabel() string {
|
||||
if md.IsAudio() {
|
||||
return "Time Listening"
|
||||
}
|
||||
return "Time Reading"
|
||||
}
|
||||
|
||||
// AudioDuration lazily parses duration_seconds out of audio_metadata.
|
||||
func (md *MediaDetail) AudioDuration() float64 {
|
||||
if !md.audioParsed {
|
||||
md.audioParsed = true
|
||||
var payload struct {
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
}
|
||||
if len(md.AudioMetadata) > 0 && json.Unmarshal(md.AudioMetadata, &payload) == nil {
|
||||
md.audioDurationSeconds = payload.DurationSeconds
|
||||
}
|
||||
}
|
||||
return md.audioDurationSeconds
|
||||
}
|
||||
|
||||
// AudioDurationLabel renders H:MM:SS (or M:SS under an hour) for the
|
||||
// audio surfaces — same shape the web player shows.
|
||||
func (md *MediaDetail) AudioDurationLabel() string {
|
||||
s := int(md.AudioDuration() + 0.5)
|
||||
if s <= 0 {
|
||||
return "—"
|
||||
}
|
||||
h := s / 3600
|
||||
m := (s % 3600) / 60
|
||||
sec := s % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, sec)
|
||||
}
|
||||
|
||||
// AudioCurrentSeconds is the playhead in seconds: percentage of the
|
||||
// whole-book duration (chapter-exact granularity lives in the player;
|
||||
// the detail page shows shelf-level position).
|
||||
func (md *MediaDetail) AudioCurrentSeconds() int {
|
||||
dur := md.AudioDuration()
|
||||
if dur <= 0 || md.ReadingProgress == nil || !md.ReadingProgress.Percentage.Valid {
|
||||
return 0
|
||||
}
|
||||
return int(md.ReadingProgress.Percentage.Float64*dur + 0.5)
|
||||
}
|
||||
|
||||
// AudioCurrentLabel is the playhead rendered H:MM:SS.
|
||||
func (md *MediaDetail) AudioCurrentLabel() string {
|
||||
s := md.AudioCurrentSeconds()
|
||||
if s <= 0 {
|
||||
return "0:00"
|
||||
}
|
||||
h := s / 3600
|
||||
m := (s % 3600) / 60
|
||||
sec := s % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, sec)
|
||||
}
|
||||
|
||||
// AudioStatsLabel is "Time listened" for audio items (H:MM:SS), honoring
|
||||
// the plan's swap of the Pages Synced tile.
|
||||
func (md *MediaDetail) TimeListenedLabel() string {
|
||||
s := 0
|
||||
if md.BookStats != nil {
|
||||
s = md.BookStats.TotalSeconds
|
||||
}
|
||||
h := s / 3600
|
||||
m := (s % 3600) / 60
|
||||
sec := s % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, sec)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
wsync "bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -148,6 +149,23 @@ type ProgressWithMedia struct {
|
||||
LastUpdated string `json:"-"`
|
||||
FormatGroup string `json:"format_group"`
|
||||
EstimatedPages int `json:"estimated_pages"`
|
||||
// Audiobook whole-book duration (seconds) for H:M:S progress display
|
||||
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// audioDurationFrom pulls duration_seconds from the scan's audio_metadata
|
||||
// JSONB (0 when the item isn't audio or hasn't been probed).
|
||||
func audioDurationFrom(raw []byte) float64 {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
}
|
||||
var payload struct {
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil {
|
||||
return 0
|
||||
}
|
||||
return payload.DurationSeconds
|
||||
}
|
||||
|
||||
// GetAllProgress retrieves all progress for a user with sync source info
|
||||
@@ -282,7 +300,10 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
DeviceName: deviceName,
|
||||
DeviceType: deviceName,
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
// Audiobook "current position" needs whole-book duration
|
||||
// (H:M:S display); parsed from the scanner's audio_metadata.
|
||||
DurationSeconds: audioDurationFrom(mediaItem.AudioMetadata),
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
FormatGroup: item.FormatGroup,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -469,9 +469,22 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Get only visible sections for the dashboard display
|
||||
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
||||
|
||||
// Active library type drives the per-type wording (A2c): system
|
||||
// collection titles and stat labels swap on audiobooks scope; the
|
||||
// "All libraries" view keeps generic wording.
|
||||
activeLibType := ""
|
||||
if !libRes.IsAll {
|
||||
for _, ld := range libRes.Libraries {
|
||||
if ld.ID == libraryID {
|
||||
activeLibType = ld.TypeValue
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID, activeLibType))
|
||||
allSectionsData := handlers.BuildSections(allSections, libraryID, activeLibType)
|
||||
|
||||
// "This week" widget: the user's rolling 7-day reading pulse for
|
||||
// the CURRENTLY SELECTED library (the same resolution the sections
|
||||
@@ -502,7 +515,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, errorMsg).Render(c.Request().Context(), &buf)
|
||||
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -870,6 +883,15 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, weekErr.Error())
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
widgetLibType := ""
|
||||
if !libRes.IsAll {
|
||||
for _, ld := range libRes.Libraries {
|
||||
if ld.ID == libRes.LibraryID {
|
||||
widgetLibType = ld.TypeValue
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
err = templates.DashboardWeekWidget(templates.WeekStatsData{
|
||||
WeekMinutes: weekSvc.WeekMinutes,
|
||||
WeekPages: weekSvc.WeekPages,
|
||||
@@ -879,7 +901,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
ActiveToday: weekSvc.ActiveToday,
|
||||
CurrentStreak: weekSvc.CurrentStreak,
|
||||
LibraryID: libRes.LibraryID,
|
||||
}).Render(c.Request().Context(), &buf)
|
||||
}, widgetLibType).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1672,6 +1694,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 {
|
||||
bookStats = &handlers.BookStatsData{
|
||||
TotalMinutes: svcStats.TotalMinutes,
|
||||
TotalSeconds: svcStats.TotalSeconds,
|
||||
TotalPages: svcStats.TotalPages,
|
||||
SessionCount: svcStats.SessionCount,
|
||||
MaxProgress: svcStats.MaxProgress,
|
||||
|
||||
@@ -197,6 +197,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
TypeValue: lib.TypeName,
|
||||
MediaCount: countMap[libUUID.String()],
|
||||
Offline: offline,
|
||||
}
|
||||
|
||||
@@ -93,6 +93,12 @@ type MediaMetadata struct {
|
||||
// Audiobook fields (media_items.audio_metadata JSONB — only the
|
||||
// audio paths read it; see media_scanner_audio.go)
|
||||
AudioMeta *AudioMetadataPayload
|
||||
|
||||
// Narrators/abridged land in real media_items columns (A2a): the
|
||||
// editorial surface of audiobooks (edit form, filters, search),
|
||||
// while AudioMeta stays scan-derived.
|
||||
Narrators []string
|
||||
Abridged bool
|
||||
}
|
||||
|
||||
type HashInfo struct {
|
||||
@@ -941,6 +947,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Contributors: metadata.Contributors,
|
||||
ContributorsSearch: contributorsSearch,
|
||||
Narrators: metadata.Narrators,
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Tags: metadata.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
AddedByAdminID: s.adminID,
|
||||
@@ -1095,6 +1104,8 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Authors []string `json:"authors"`
|
||||
Narrators []string `json:"narrators"`
|
||||
Abridged *bool `json:"abridged"`
|
||||
Series json.RawMessage `json:"series"`
|
||||
Genres []string `json:"genres"`
|
||||
Tags []string `json:"tags"`
|
||||
@@ -1173,6 +1184,14 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
if sidecar.ASIN != nil {
|
||||
metadata.ASIN = strings.TrimSpace(*sidecar.ASIN)
|
||||
}
|
||||
// Audiobook editorial facts land in the real columns (A2a):
|
||||
// narrators verbatim (display), abridged as the Libation/Audible flag.
|
||||
if len(sidecar.Narrators) > 0 {
|
||||
metadata.Narrators = sidecar.Narrators
|
||||
}
|
||||
if sidecar.Abridged != nil {
|
||||
metadata.Abridged = *sidecar.Abridged
|
||||
}
|
||||
if sidecar.Language != nil {
|
||||
metadata.Language = strings.TrimSpace(*sidecar.Language)
|
||||
}
|
||||
@@ -3191,6 +3210,9 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
|
||||
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Narrators: metadata.Narrators,
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Tags: metadata.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||
|
||||
@@ -42,6 +42,7 @@ type AudioMetadataPayload struct {
|
||||
CodecProfile string `json:"codec_profile,omitempty"`
|
||||
BitRate int64 `json:"bit_rate,omitempty"`
|
||||
ASIN string `json:"asin,omitempty"`
|
||||
Abridged *bool `json:"abridged,omitempty"`
|
||||
Chapters []AudioChapter `json:"chapters,omitempty"`
|
||||
}
|
||||
|
||||
@@ -182,14 +183,24 @@ func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata)
|
||||
if payload.ASIN == "" {
|
||||
payload.ASIN = metadata.ASIN
|
||||
}
|
||||
|
||||
// Promote the editorial facts (A2a columns) when the sidecar's audio
|
||||
// payload carries them and the common-field parse left them empty.
|
||||
if len(metadata.Narrators) == 0 && len(payload.Narrators) > 0 {
|
||||
metadata.Narrators = payload.Narrators
|
||||
}
|
||||
if payload.Abridged != nil {
|
||||
metadata.Abridged = *payload.Abridged
|
||||
}
|
||||
}
|
||||
|
||||
// parseSidecarAudioFields extracts the audio-only fields from a
|
||||
// Libation/Audiobookshelf metadata.json into the payload (narrators and
|
||||
// the curated chapter list).
|
||||
// Libation/Audiobookshelf metadata.json into the payload (narrators,
|
||||
// abridged and the curated chapter list).
|
||||
func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
|
||||
var sidecar struct {
|
||||
Narrators []string `json:"narrators"`
|
||||
Abridged *bool `json:"abridged"`
|
||||
ASIN *string `json:"asin"`
|
||||
Chapters []struct {
|
||||
Start float64 `json:"start"`
|
||||
@@ -201,11 +212,12 @@ func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
|
||||
if err := json.Unmarshal(data, &sidecar); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil {
|
||||
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil && sidecar.Abridged == nil {
|
||||
return nil
|
||||
}
|
||||
payload := &AudioMetadataPayload{
|
||||
Narrators: sidecar.Narrators,
|
||||
Abridged: sidecar.Abridged,
|
||||
}
|
||||
if sidecar.ASIN != nil {
|
||||
payload.ASIN = strings.TrimSpace(*sidecar.ASIN)
|
||||
|
||||
@@ -29,6 +29,7 @@ type SearchParams struct {
|
||||
GenreFilter string
|
||||
PublisherFilter string
|
||||
TagsFilter string
|
||||
NarratorFilter string
|
||||
LanguageFilter string
|
||||
YearMin int
|
||||
YearMax int
|
||||
@@ -76,6 +77,7 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
|
||||
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
|
||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
@@ -174,6 +176,23 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
|
||||
}
|
||||
return fieldValues, nil
|
||||
|
||||
case "narrator":
|
||||
results, err := s.db.SearchNarratorValues(ctx, database.SearchNarratorValuesParams{
|
||||
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
|
||||
UserID: params.UserID,
|
||||
LibraryID: params.LibraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fieldValues := make([]FieldValue, len(results))
|
||||
for i, r := range results {
|
||||
fieldValues[i] = FieldValue{Value: r.Value, Count: r.Count, Score: r.Score}
|
||||
}
|
||||
return fieldValues, nil
|
||||
|
||||
case "series":
|
||||
results, err := s.db.SearchSeriesValues(ctx, database.SearchSeriesValuesParams{
|
||||
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
|
||||
|
||||
@@ -163,6 +163,7 @@ type WeekSummaryStats struct {
|
||||
type BookStats struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
TotalMinutes int `json:"total_minutes"`
|
||||
TotalSeconds int `json:"total_seconds"` // exact wall-clock (audio "Time listened"; minutes lose the remainder)
|
||||
TotalPages int `json:"total_pages"`
|
||||
SessionCount int `json:"session_count"`
|
||||
MaxProgress float64 `json:"max_progress"`
|
||||
@@ -542,6 +543,7 @@ func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, m
|
||||
}
|
||||
for i, sess := range sessions {
|
||||
if sess.TimeSpentSeconds.Valid {
|
||||
out.TotalSeconds += int(sess.TimeSpentSeconds.Int32)
|
||||
out.TotalMinutes += int(sess.TimeSpentSeconds.Int32) / 60
|
||||
}
|
||||
if sess.PagesRead.Valid {
|
||||
|
||||
@@ -23,6 +23,8 @@ const (
|
||||
OverrideDescription = "description"
|
||||
OverrideCoverImagePath = "cover_image_path"
|
||||
OverrideSeries = "series"
|
||||
OverrideNarrators = "narrators"
|
||||
OverrideAbridged = "abridged"
|
||||
OverrideSeriesNumber = "series_number"
|
||||
OverrideTags = "tags"
|
||||
OverrideAsin = "asin"
|
||||
@@ -154,6 +156,8 @@ func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing
|
||||
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
|
||||
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
|
||||
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
|
||||
add(OverrideNarrators, normStringSlice(params.Narrators) != normStringSlice(existing.Narrators))
|
||||
add(OverrideAbridged, params.Abridged != existing.Abridged)
|
||||
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
|
||||
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
|
||||
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
|
||||
@@ -223,6 +227,13 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
|
||||
params.Tags = existing.Tags
|
||||
params.TagsSearch = existing.TagsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideNarrators) {
|
||||
params.Narrators = existing.Narrators
|
||||
params.NarratorsSearch = existing.NarratorsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideAbridged) {
|
||||
params.Abridged = existing.Abridged
|
||||
}
|
||||
if hasOverride(o, OverrideAsin) {
|
||||
params.Asin = existing.Asin
|
||||
}
|
||||
|
||||
@@ -176,6 +176,13 @@ func NormalizeTagsSearch(tags []string) []string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// NormalizeNarratorsSearch mirrors NormalizeTagsSearch for narrator names —
|
||||
// the trigram-search TEXT[] paired with media_items.narrators (filters and
|
||||
// free-text search hit this).
|
||||
func NormalizeNarratorsSearch(narrators []string) []string {
|
||||
return NormalizeTagsSearch(narrators)
|
||||
}
|
||||
|
||||
// NormalizeContributors normalizes an array of contributors for display:
|
||||
// 1. Trim whitespace
|
||||
// 2. Preserve original casing (including CAPSLOCK companies)
|
||||
|
||||
+266
-117
@@ -33,134 +33,283 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||||
>
|
||||
<div class="h-full flex flex-col items-center justify-center px-4 gap-6 select-none">
|
||||
<!-- Cover + title -->
|
||||
<div class="flex flex-col items-center gap-3 text-center">
|
||||
<div class="w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center" style="background-color: var(--surface-hover);">
|
||||
if metadata.CoverImagePath != "" {
|
||||
<img
|
||||
src={ metadata.CoverImagePath }
|
||||
alt={ metadata.Title }
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
} else {
|
||||
<span class="material-symbols-rounded text-5xl flex items-center justify-center h-full" style="color: var(--text-secondary);">headphones</span>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="font-semibold text-lg leading-snug" style="color: var(--text-primary);">{ metadata.Title }</h1>
|
||||
<p class="text-sm mt-0.5" style="color: var(--text-secondary);" x-text="chapterTitle()">—</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Header: back to the detail page (reader-style exit) and the
|
||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
||||
<div class="absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="backToDetail()"
|
||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||
style="color: var(--text-primary);"
|
||||
aria-label="Back to book details"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 12H5"></path>
|
||||
<path d="m12 19-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="bookmarksOpen = !bookmarksOpen"
|
||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
|
||||
:aria-label="'Open bookmarks (' + bookmarks.length + ')'"
|
||||
title="Bookmarks"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="chaptersOpen = !chaptersOpen"
|
||||
class="text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
|
||||
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
|
||||
:aria-label="'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'"
|
||||
title="Chapters"
|
||||
>
|
||||
<span>Chapter</span>
|
||||
<span class="tabular-nums" x-text="(currentChapterIdx + 1) + '/' + (chapters.length || 1)">1/1</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Scrubber + transports -->
|
||||
<div class="w-full max-w-xl flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
|
||||
<span x-text="fmtTime(currentTime)">0:00</span>
|
||||
<input
|
||||
id="audio-scrub"
|
||||
type="range"
|
||||
min="0"
|
||||
step="0.1"
|
||||
:value="chaptersDuration() || 1"
|
||||
class="flex-1 accent-current"
|
||||
@input.debounce.300ms="seekTo($event.target.value)"
|
||||
<!-- Cover + title -->
|
||||
<div class="flex flex-col items-center gap-3 text-center">
|
||||
<div class="w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center" style="background-color: var(--surface-hover);">
|
||||
if metadata.CoverImagePath != "" {
|
||||
<img
|
||||
src={ metadata.CoverImagePath }
|
||||
alt={ metadata.Title }
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="skip(-1)"
|
||||
class="rounded-full p-3 hover:bg-surface-hover transition-colors"
|
||||
:aria-label="'Skip back ' + skipSeconds + 's'"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="text-lg font-semibold">-<span x-text="skipSeconds">10</span>s</span>
|
||||
</button>
|
||||
<button
|
||||
id="audio-play"
|
||||
type="button"
|
||||
@click="togglePlay()"
|
||||
class="rounded-full h-16 w-16 grid place-items-center"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
aria-label="Play or pause"
|
||||
>
|
||||
<span x-show="!playing" class="text-3xl leading-none">▶</span>
|
||||
<span x-show="playing" class="text-3xl leading-none" x-cloak>❚❚</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="skip(1)"
|
||||
class="rounded-full p-3 hover:bg-surface-hover transition-colors"
|
||||
:aria-label="'Skip forward ' + skipSeconds + 's'"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="text-lg font-semibold">+<span x-text="skipSeconds">10</span>s</span>
|
||||
</button>
|
||||
</div>
|
||||
} else {
|
||||
<span class="text-5xl flex items-center justify-center h-full" style="color: var(--text-secondary);">🎧</span>
|
||||
}
|
||||
</div>
|
||||
<div class="max-w-md px-4">
|
||||
<h1 class="font-semibold text-lg leading-snug line-clamp-2" style="color: var(--text-primary);">{ metadata.Title }</h1>
|
||||
<p class="text-sm mt-0.5 truncate" style="color: var(--text-secondary);" x-text="chapterTitle()">—</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list + speed -->
|
||||
<div class="w-full max-w-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
@click="chaptersOpen = !chaptersOpen"
|
||||
class="text-sm px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
Chapters <span x-text="'(' + chapters.length + ')'"></span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs" style="color: var(--text-secondary);">Skip</span>
|
||||
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
|
||||
<button
|
||||
type="button"
|
||||
@click="setSkip(secs)"
|
||||
class="text-xs px-2 py-1 rounded-md transition-colors"
|
||||
:class="Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
|
||||
style="color: var(--text-secondary);"
|
||||
x-text="secs + 's'"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-xs" style="color: var(--text-secondary);">Speed</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="rate">
|
||||
<button
|
||||
type="button"
|
||||
@click="setSpeed(rate)"
|
||||
class="text-xs px-2 py-1 rounded-md transition-colors"
|
||||
:class="Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
|
||||
style="color: var(--text-secondary);"
|
||||
x-text="rate + '×'"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="chaptersOpen" x-cloak class="mt-2 max-h-64 overflow-y-auto rounded-xl border p-2" style="border-color: var(--border); background-color: var(--bg-secondary);">
|
||||
<template x-for="ch in chapters" :key="ch.index">
|
||||
<!-- Scrubber + transports -->
|
||||
<div class="w-full max-w-xl flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
|
||||
<span x-text="fmtTime(currentTime)">0:00</span>
|
||||
<input
|
||||
id="audio-scrub"
|
||||
type="range"
|
||||
min="0"
|
||||
step="0.1"
|
||||
:max="chaptersDuration() || 1"
|
||||
:value="scrubbing ? scrubValue : currentTime"
|
||||
class="flex-1 accent-current"
|
||||
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
||||
@pointerup="seekTo(scrubValue); scrubbing = false"
|
||||
@change="seekTo(scrubValue); scrubbing = false"
|
||||
style="z-index: 20;"
|
||||
/>
|
||||
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="skip(-1)"
|
||||
class="rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors"
|
||||
:aria-label="'Skip back ' + skipSeconds + 's'"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="text-base font-semibold">–<span x-text="skipSeconds">10</span>s</span>
|
||||
</button>
|
||||
<button
|
||||
id="audio-play"
|
||||
type="button"
|
||||
@click="togglePlay()"
|
||||
class="rounded-full h-16 w-16 grid place-items-center"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
aria-label="Play or pause"
|
||||
>
|
||||
<span x-show="!playing" class="text-3xl leading-none">▶</span>
|
||||
<span x-show="playing" class="text-3xl leading-none" x-cloak>❚❚</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="skip(1)"
|
||||
class="rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors"
|
||||
:aria-label="'Skip forward ' + skipSeconds + 's'"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="text-base font-semibold">+<span x-text="skipSeconds">10</span>s</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleBookmarkCapture()"
|
||||
class="rounded-full p-2.5 ml-2 hover:bg-surface-hover transition-colors"
|
||||
:class="bookmarkCaptureOpen ? 'opacity-100' : 'opacity-60'"
|
||||
aria-label="Add bookmark at current position"
|
||||
title="Add bookmark"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
|
||||
<path d="M12 7v6M9 10h6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skip / Speed rows: one layout rule, labels left, chips right -->
|
||||
<div class="w-full max-w-xl flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Skip</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
|
||||
<button
|
||||
type="button"
|
||||
@click="jumpToChapter(ch.index)"
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm flex justify-between gap-3 hover:bg-surface-hover transition-colors"
|
||||
:class="chapters.length && currentChapter() === ch.index ? 'font-semibold' : ''"
|
||||
@click="setSkip(secs)"
|
||||
class="text-xs px-2.5 py-1.5 rounded-md transition-colors"
|
||||
:class="Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<span class="truncate" x-text="ch.title || ('Chapter ' + (ch.index + 1))"></span>
|
||||
<span class="shrink-0 text-xs tabular-nums" x-text="fmtTime(ch.start)"></span>
|
||||
</button>
|
||||
x-text="secs + 's'"
|
||||
></button>
|
||||
</template>
|
||||
<p x-show="!chapters.length" class="text-sm text-center py-4" style="color: var(--text-secondary);">
|
||||
No chapters detected for this audiobook.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Speed</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="rate">
|
||||
<button
|
||||
type="button"
|
||||
@click="setSpeed(rate)"
|
||||
class="text-xs px-2.5 py-1.5 rounded-md transition-colors"
|
||||
:class="Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
|
||||
style="color: var(--text-secondary);"
|
||||
x-text="rate + '×'"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="audio-element" preload="auto" class="hidden"></audio>
|
||||
<!-- Chapter drawer: slides top-to-bottom, floats above the page
|
||||
(backdrop overlay — the page never shifts) -->
|
||||
<div x-show="chaptersOpen" x-cloak class="fixed inset-0 z-40" style="background-color: color-mix(in srgb, black 32%, transparent);" @click="chaptersOpen = false"></div>
|
||||
<div
|
||||
x-show="chaptersOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-4 opacity-0"
|
||||
x-transition:enter-end="translate-y-0 opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="translate-y-0 opacity-100"
|
||||
x-transition:leave-end="-translate-y-4 opacity-0"
|
||||
class="fixed top-0 right-0 z-50 w-full sm:w-96 max-h-[70vh] overflow-y-auto rounded-b-2xl shadow-2xl p-3"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); border-top-width: 0;"
|
||||
@click.outside="chaptersOpen = false"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Chapters</span>
|
||||
<button type="button" @click="chaptersOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close chapters">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<template x-for="ch in chapters" :key="ch.index">
|
||||
<button
|
||||
type="button"
|
||||
@click="jumpToChapter(ch.index)"
|
||||
class="w-full text-left px-3 py-2.5 rounded-lg text-sm flex justify-between gap-3 hover:bg-surface-hover transition-colors"
|
||||
:class="currentChapter() === ch.index ? 'font-semibold' : ''"
|
||||
:style="currentChapter() === ch.index ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||
>
|
||||
<span class="truncate" x-text="ch.title || ('Chapter ' + (ch.index + 1))"></span>
|
||||
<span class="shrink-0 text-xs tabular-nums" x-text="fmtTime(ch.start)"></span>
|
||||
</button>
|
||||
</template>
|
||||
<p x-show="!chapters.length" class="text-sm text-center py-4" style="color: var(--text-secondary);">
|
||||
No chapters detected for this audiobook.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bookmarks drawer: same slide-down panel shape as chapters;
|
||||
note is the point (A2d) -->
|
||||
<div
|
||||
x-show="bookmarksOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-4 opacity-0"
|
||||
x-transition:enter-end="translate-y-0 opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="translate-y-0 opacity-100"
|
||||
x-transition:leave-end="-translate-y-4 opacity-0"
|
||||
class="fixed top-0 right-0 z-50 w-full sm:w-96 max-h-[70vh] overflow-y-auto rounded-b-2xl shadow-2xl p-3"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); border-top-width: 0;"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Bookmarks</span>
|
||||
<button type="button" @click="bookmarksOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close bookmarks">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<template x-for="b in bookmarks" :key="String(b.id)">
|
||||
<div class="px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors">
|
||||
<div class="flex justify-between items-center gap-3">
|
||||
<button type="button" class="flex-1 text-left min-w-0" @click="jumpToBookmark(b)">
|
||||
<span class="text-sm font-medium truncate block" style="color: var(--text-primary);" x-text="b.title"></span>
|
||||
</button>
|
||||
<div class="shrink-0 flex items-center gap-1">
|
||||
<button type="button" @click="startEditNote(b)" class="rounded p-1 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Edit note">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path></svg>
|
||||
</button>
|
||||
<button type="button" @click="deleteBookmark(b)" class="rounded p-1 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Delete bookmark">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs mt-1 line-clamp-2" x-show="b.notes && editingBookmarkId !== String(b.id)" style="color: var(--text-secondary);" x-text="b.notes"></p>
|
||||
<div x-show="editingBookmarkId === String(b.id)" x-cloak class="mt-1">
|
||||
<input type="text" x-model="editingNote" class="input text-sm" placeholder="Note" @keydown.enter.prevent="saveNote()"/>
|
||||
<div class="flex justify-end gap-2 mt-1">
|
||||
<button type="button" @click="editingBookmarkId = ''" class="btn btn-ghost text-xs px-2 py-1">Cancel</button>
|
||||
<button type="button" @click="saveNote()" class="btn btn-primary text-xs px-2 py-1">Save note</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!bookmarks.length" class="text-sm text-center py-4" style="color: var(--text-secondary);">
|
||||
No bookmarks yet — tap the <span class="inline-flex align-middle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg></span> button to save your position.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bookmark capture pill: floats under the header while playing
|
||||
position is live; optional note per the A2d spec -->
|
||||
<div
|
||||
x-show="bookmarkCaptureOpen"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed top-16 right-4 z-50 w-80 rounded-xl shadow-2xl p-3"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
|
||||
>
|
||||
<p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p>
|
||||
<input
|
||||
type="text"
|
||||
x-model="bookmarkNote"
|
||||
placeholder="Optional note (e.g. 'great quote')"
|
||||
class="input mb-2"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
|
||||
<button type="button" @click="saveBookmark()" class="btn btn-primary text-sm px-3 py-1.5">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="audio-element" preload="auto" class="hidden"></audio>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -118,7 +118,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 41, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 86, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -143,7 +143,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 42, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 87, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -154,25 +154,25 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"material-symbols-rounded text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">headphones</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div><h1 class=\"font-semibold text-lg leading-snug\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 51, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 96, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :value=\"chaptersDuration() || 1\" class=\"flex-1 accent-current\" @input.debounce.300ms=\"seekTo($event.target.value)\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-6\"><button type=\"button\" @click=\"skip(-1)\" class=\"rounded-full p-3 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-lg font-semibold\">-<span x-text=\"skipSeconds\">10</span>s</span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"text-3xl leading-none\">▶</span> <span x-show=\"playing\" class=\"text-3xl leading-none\" x-cloak>❚❚</span></button> <button type=\"button\" @click=\"skip(1)\" class=\"rounded-full p-3 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-lg font-semibold\">+<span x-text=\"skipSeconds\">10</span>s</span></button></div></div><!-- Chapter list + speed --><div class=\"w-full max-w-xl\"><div class=\"flex items-center justify-between\"><button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors\" style=\"color: var(--text-secondary);\">Chapters <span x-text=\"'(' + chapters.length + ')'\"></span></button><div class=\"flex items-center gap-1.5\"><span class=\"text-xs\" style=\"color: var(--text-secondary);\">Skip</span><template x-for=\"secs in [1, 5, 10, 15, 30]\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"setSkip(secs)\" class=\"text-xs px-2 py-1 rounded-md transition-colors\" :class=\"Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"secs + 's'\"></button></template></div></div><div class=\"flex items-center justify-between mt-1\"><span class=\"text-xs\" style=\"color: var(--text-secondary);\">Speed</span><div class=\"flex items-center gap-1.5\"><template x-for=\"rate in [0.75, 1, 1.25, 1.5, 2]\" :key=\"rate\"><button type=\"button\" @click=\"setSpeed(rate)\" class=\"text-xs px-2 py-1 rounded-md transition-colors\" :class=\"Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"rate + '×'\"></button></template></div></div><div x-show=\"chaptersOpen\" x-cloak class=\"mt-2 max-h-64 overflow-y-auto rounded-xl border p-2\" style=\"border-color: var(--border); background-color: var(--bg-secondary);\"><template x-for=\"ch in chapters\" :key=\"ch.index\"><button type=\"button\" @click=\"jumpToChapter(ch.index)\" class=\"w-full text-left px-3 py-2 rounded-lg text-sm flex justify-between gap-3 hover:bg-surface-hover transition-colors\" :class=\"chapters.length && currentChapter() === ch.index ? 'font-semibold' : ''\" style=\"color: var(--text-secondary);\"><span class=\"truncate\" x-text=\"ch.title || ('Chapter ' + (ch.index + 1))\"></span> <span class=\"shrink-0 text-xs tabular-nums\" x-text=\"fmtTime(ch.start)\"></span></button></template><p x-show=\"!chapters.length\" class=\"text-sm text-center py-4\" style=\"color: var(--text-secondary);\">No chapters detected for this audiobook.</p></div></div><audio id=\"audio-element\" preload=\"auto\" class=\"hidden\"></audio></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-6\"><button type=\"button\" @click=\"skip(-1)\" class=\"rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-base font-semibold\">–<span x-text=\"skipSeconds\">10</span>s</span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"text-3xl leading-none\">▶</span> <span x-show=\"playing\" class=\"text-3xl leading-none\" x-cloak>❚❚</span></button> <button type=\"button\" @click=\"skip(1)\" class=\"rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-base font-semibold\">+<span x-text=\"skipSeconds\">10</span>s</span></button> <button type=\"button\" @click=\"toggleBookmarkCapture()\" class=\"rounded-full p-2.5 ml-2 hover:bg-surface-hover transition-colors\" :class=\"bookmarkCaptureOpen ? 'opacity-100' : 'opacity-60'\" aria-label=\"Add bookmark at current position\" title=\"Add bookmark\" style=\"color: var(--text-primary);\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path> <path d=\"M12 7v6M9 10h6\"></path></svg></button></div></div><!-- Skip / Speed rows: one layout rule, labels left, chips right --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center justify-between\"><span class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Skip</span><div class=\"flex items-center gap-1.5\"><template x-for=\"secs in [1, 5, 10, 15, 30]\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"setSkip(secs)\" class=\"text-xs px-2.5 py-1.5 rounded-md transition-colors\" :class=\"Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"secs + 's'\"></button></template></div></div><div class=\"flex items-center justify-between\"><span class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Speed</span><div class=\"flex items-center gap-1.5\"><template x-for=\"rate in [0.75, 1, 1.25, 1.5, 2]\" :key=\"rate\"><button type=\"button\" @click=\"setSpeed(rate)\" class=\"text-xs px-2.5 py-1.5 rounded-md transition-colors\" :class=\"Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'\" style=\"color: var(--text-secondary);\" x-text=\"rate + '×'\"></button></template></div></div></div><!-- Chapter drawer: slides top-to-bottom, floats above the page\n\t\t\t (backdrop overlay — the page never shifts) --><div x-show=\"chaptersOpen\" x-cloak class=\"fixed inset-0 z-40\" style=\"background-color: color-mix(in srgb, black 32%, transparent);\" @click=\"chaptersOpen = false\"></div><div x-show=\"chaptersOpen\" x-cloak x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"-translate-y-4 opacity-0\" x-transition:enter-end=\"translate-y-0 opacity-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"translate-y-0 opacity-100\" x-transition:leave-end=\"-translate-y-4 opacity-0\" class=\"fixed top-0 right-0 z-50 w-full sm:w-96 max-h-[70vh] overflow-y-auto rounded-b-2xl shadow-2xl p-3\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); border-top-width: 0;\" @click.outside=\"chaptersOpen = false\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Chapters</span> <button type=\"button\" @click=\"chaptersOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-labLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+53
-16
@@ -68,18 +68,23 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
by { book.Author.String }
|
||||
</h2>
|
||||
}
|
||||
if book.IsAudio() && len(book.Narrators) > 0 {
|
||||
<h3 class="text-sm md:text-base mb-5" style="color: var(--text-secondary)">
|
||||
Narrated by { strings.Join(book.Narrators, ", ") }
|
||||
</h3>
|
||||
}
|
||||
<!-- Action buttons -->
|
||||
<div class="flex flex-wrap gap-2.5 mb-6">
|
||||
<button
|
||||
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
|
||||
class="btn btn-primary px-5 py-2.5"
|
||||
>
|
||||
@Icon("book-open", "h-4 w-4")
|
||||
<span>Read Now</span>
|
||||
@Icon(book.ActionIcon(), "h-4 w-4")
|
||||
<span>{ book.ActionLabel() }</span>
|
||||
</button>
|
||||
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
||||
<button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
|
||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4")<span>Mark Unread</span></span>
|
||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4")<span>{ book.MarkResetLabel() }</span></span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
@@ -87,7 +92,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
</button>
|
||||
} else {
|
||||
<button @click="toggleRead(true)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
|
||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check", "h-4 w-4")<span>Mark Read</span></span>
|
||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check", "h-4 w-4")<span>{ book.MarkCompleteLabel() }</span></span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
@@ -262,7 +267,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
<!-- Progress -->
|
||||
if book.ReadingProgress != nil {
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">{ book.ProgressHeader() }</h3>
|
||||
if book.ActiveConflict != nil {
|
||||
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border: 1px solid var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5 shrink-0")
|
||||
@@ -278,10 +283,17 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Progress</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--accent);">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
|
||||
</div>
|
||||
if book.IsAudio() {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Position</p>
|
||||
<p class="text-2xl font-bold tabular-nums" style="color: var(--accent);">{ book.AudioCurrentLabel() + " / " + book.AudioDurationLabel() }</p>
|
||||
</div>
|
||||
} else {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Progress</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--accent);">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Page</p>
|
||||
@@ -290,7 +302,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
}
|
||||
if book.ReadingProgress.LastReadAt.Valid {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Last Read</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">{ book.LastReadLabel() }</p>
|
||||
<p class="text-sm" style="color: var(--text-primary)">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||
</div>
|
||||
}
|
||||
@@ -306,10 +318,10 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
<!-- Reading stats (the viewer's own sessions on this book) -->
|
||||
if book.BookStats != nil {
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Your Reading Stats</h3>
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">{ book.StatsHeader() }</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Time Reading</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">{ book.TimeStatLabel() }</p>
|
||||
if book.BookStats.TotalMinutes >= 60 {
|
||||
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60) }</p>
|
||||
} else {
|
||||
@@ -320,10 +332,17 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
|
||||
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.SessionCount) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages Synced</p>
|
||||
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.TotalPages) }</p>
|
||||
</div>
|
||||
if book.IsAudio() {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Time listened</p>
|
||||
<p class="text-xl font-bold tabular-nums" style="color: var(--accent)">{ book.TimeListenedLabel() }</p>
|
||||
</div>
|
||||
} else {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages Synced</p>
|
||||
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.TotalPages) }</p>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Finished</p>
|
||||
if book.BookStats.Finished {
|
||||
@@ -363,6 +382,24 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
<p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.IsAudio() && len(book.Narrators) > 0 {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Narrator</p>
|
||||
<p style="color: var(--text-primary)">{ strings.Join(book.Narrators, ", ") }</p>
|
||||
</div>
|
||||
}
|
||||
if book.IsAudio() && book.AudioDuration() > 0 {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Duration</p>
|
||||
<p class="tabular-nums" style="color: var(--text-primary)">{ book.AudioDurationLabel() }</p>
|
||||
</div>
|
||||
}
|
||||
if book.IsAudio() && book.Abridged {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Abridged</p>
|
||||
<p style="color: var(--text-primary)">Yes</p>
|
||||
</div>
|
||||
}
|
||||
if book.Edition.Valid && book.Edition.String != "" {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ package templates
|
||||
import (
|
||||
"bookhoard/internal/handlers"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
@@ -501,6 +502,37 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
if book.IsAudio() {
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('audio')">
|
||||
<span class="font-semibold">Audio</span>
|
||||
<span class="inline-flex">
|
||||
<span x-show="openSections.audio">@Icon("chevron-down", "h-4 w-4")</span>
|
||||
<span x-show="!openSections.audio" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="openSections.audio" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Narrator(s)</label>
|
||||
<input type="text" name="narrators" value={ strings.Join(book.Narrators, ", ") } class="input" placeholder="Comma-separated names"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||
<input type="checkbox" name="abridged" checked={ book.Abridged } class="h-4 w-4 accent-current"/>
|
||||
<span>Abridged edition</span>
|
||||
</label>
|
||||
</div>
|
||||
if book.AudioDuration() > 0 {
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Duration (scanned)</label>
|
||||
<p class="input opacity-60 cursor-default tabular-nums" style="color: var(--text-primary);">{ book.AudioDurationLabel() }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
|
||||
@@ -11,6 +11,7 @@ import templruntime "github.com/a-h/templ/runtime"
|
||||
import (
|
||||
"bookhoard/internal/handlers"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
@@ -49,7 +50,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 25, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 26, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -88,7 +89,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 57, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 58, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -101,7 +102,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(data.Timestamp, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 60, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 61, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +120,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", data.Data["percentage"].(float64)*100))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 66, Col: 73}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 67, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -147,7 +148,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", data.Data["page"].(float64)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 74, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 75, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -160,7 +161,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 74, Col: 128}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 75, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -174,7 +175,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", data.Data["page"].(float64)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 76, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 77, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -194,7 +195,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Data["epubcfi"].(string))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 82, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 83, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -212,7 +213,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 87, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 88, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -235,7 +236,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 98, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 99, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -248,7 +249,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 102, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 103, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -261,7 +262,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 105, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 106, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -279,7 +280,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 109, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 110, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -292,7 +293,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 109, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 110, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -311,7 +312,7 @@ func ProgressSyncModal(user User, book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 114, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 115, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -371,7 +372,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 148, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 149, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -392,7 +393,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.HighlightsCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 163, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 164, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -405,7 +406,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.NotesCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 167, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 168, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -418,7 +419,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 171, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 172, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -441,7 +442,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 183, Col: 125}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 184, Col: 125}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -464,7 +465,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tombstoneDays)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 189, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 190, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -492,7 +493,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(ann.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 196, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 197, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -505,7 +506,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(ann.AnnotationType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 199, Col: 119}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 200, Col: 119}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -518,7 +519,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(ann.DeletedAt, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 201, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 202, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -531,7 +532,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("restoreDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 206, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 207, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -552,7 +553,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("purgeDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 213, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 214, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -578,7 +579,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(ann.DisplayText)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 223, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 224, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -602,7 +603,7 @@ func NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays in
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(ann.SecondaryText)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 228, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 229, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -680,7 +681,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 281, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 282, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -693,7 +694,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 282, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 283, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -711,7 +712,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 288, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 289, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -745,7 +746,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 323, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 324, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -758,7 +759,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 327, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 328, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -771,7 +772,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Description))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 332, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 333, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -784,7 +785,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Summary))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 337, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 338, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -810,7 +811,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 350, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 351, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -828,7 +829,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f", book.CommunityRating.Float64))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 384, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 385, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -857,7 +858,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Publisher))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 402, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 403, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -870,7 +871,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatDateForInput(book.DatePublished))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 407, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 408, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -883,7 +884,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Edition))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 412, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 413, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -896,7 +897,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Language))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 416, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 417, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -909,7 +910,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Genre))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 420, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 421, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -922,7 +923,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.CopyrightYear.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 425, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 426, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -951,7 +952,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Series))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 443, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 444, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -964,7 +965,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesNumber.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 449, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 450, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -977,7 +978,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesCount.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 455, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 456, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -990,7 +991,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.Volume.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 461, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 462, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1019,7 +1020,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var51 string
|
||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Isbn))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 480, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 481, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1032,7 +1033,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var52 string
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Asin))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 484, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 485, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1045,7 +1046,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoodreadsID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 488, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 489, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1058,7 +1059,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.OpenlibraryID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 492, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 493, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1071,7 +1072,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var55 string
|
||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoogleBooksID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 496, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 497, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1084,13 +1085,88 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
var templ_7745c5c3_Var56 string
|
||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.WebUrl))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 500, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 501, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" class=\"input\"></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.IsAudio() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('audio')\"><span class=\"font-semibold\">Audio</span> <span class=\"inline-flex\"><span x-show=\"openSections.audio\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</span> <span x-show=\"!openSections.audio\" x-cloak>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</span></span></button><div x-show=\"openSections.audio\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Narrator(s)</label> <input type=\"text\" name=\"narrators\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var57 string
|
||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.Join(book.Narrators, ", "))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 519, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\" class=\"input\" placeholder=\"Comma-separated names\"></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"abridged\" checked=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Abridged)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 523, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\" class=\"h-4 w-4 accent-current\"> <span>Abridged edition</span></label></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.AudioDuration() > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Duration (scanned)</label><p class=\"input opacity-60 cursor-default tabular-nums\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var59 string
|
||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 530, Col: 129}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1098,7 +1174,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1106,186 +1182,186 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var57 string
|
||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 518, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">Unknown</option> <option value=\"no\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 519, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">No</option> <option value=\"yes\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var59 string
|
||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 520, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var60 string
|
||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
|
||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 521, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 550, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\">Unknown</option> <option value=\"no\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var61 string
|
||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
|
||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 527, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 551, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">Auto</option> <option value=\"ltr\" selected=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\">No</option> <option value=\"yes\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var62 string
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 528, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 552, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var63 string
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 529, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 553, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var64 string
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 530, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 559, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\">Auto</option> <option value=\"ltr\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var65 string
|
||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 535, Col: 81}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 560, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var66 string
|
||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 539, Col: 79}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 561, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var67 string
|
||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 543, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 562, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var68 string
|
||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 547, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 567, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var69 string
|
||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 552, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 571, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var70 string
|
||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 575, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var71 string
|
||||
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 579, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var72 string
|
||||
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 584, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.IsBlackAndWhite.Bool {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, " checked")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1293,7 +1369,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1301,72 +1377,72 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var70 string
|
||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 576, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var71 string
|
||||
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 581, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var72 string
|
||||
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 586, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var73 string
|
||||
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
||||
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 591, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 608, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var74 string
|
||||
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
|
||||
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 597, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 613, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var75 string
|
||||
templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 618, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var76 string
|
||||
templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 623, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var77 string
|
||||
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 629, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1374,7 +1450,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1382,7 +1458,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -1390,7 +1466,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "Save</button></div></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "Save</button></div></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+976
-770
File diff suppressed because it is too large.
Load diff
@@ -220,6 +220,18 @@ templ BookShelf(
|
||||
/>
|
||||
<datalist id="series-datalist"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Narrator</label>
|
||||
<input
|
||||
type="text"
|
||||
name="narrator_filter"
|
||||
placeholder="Filter by narrator"
|
||||
class="input"
|
||||
list="narrator-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchNarratorValues($el)"
|
||||
/>
|
||||
<datalist id="narrator-datalist"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label>
|
||||
<input
|
||||
|
||||
@@ -384,7 +384,7 @@ func BookShelf(
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Narrator</label> <input type=\"text\" name=\"narrator_filter\" placeholder=\"Filter by narrator\" class=\"input\" list=\"narrator-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchNarratorValues($el)\"> <datalist id=\"narrator-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -400,7 +400,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 275, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 287, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -423,7 +423,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 284, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 296, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -454,7 +454,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 305, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -467,7 +467,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 298, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 310, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+57
-20
@@ -3,9 +3,10 @@ package templates
|
||||
import (
|
||||
"bookhoard/internal/handlers"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, errorMessage string) {
|
||||
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -25,7 +26,7 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
switch. The widget re-fetches itself via htmx instead. -->
|
||||
if week != nil {
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
|
||||
@DashboardWeekWidget(*week)
|
||||
@DashboardWeekWidget(*week, activeLibType)
|
||||
</div>
|
||||
}
|
||||
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
|
||||
@@ -130,6 +131,17 @@ templ BookCard(item handlers.BookInfo) {
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
}
|
||||
<!-- Media-type identifier (A2c): books stay unbadged — the
|
||||
shelf default already reads as a book -->
|
||||
if MediaBadgeIcon(item.FormatGroup) != "" {
|
||||
<span
|
||||
class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
|
||||
style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--text-primary); backdrop-filter: blur(3px);"
|
||||
title={ strings.Title(item.FormatGroup) + " read" }
|
||||
>
|
||||
@Icon(MediaBadgeIcon(item.FormatGroup), "h-4 w-4")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="book-card-meta">
|
||||
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||
@@ -152,6 +164,15 @@ templ BookCard(item handlers.BookInfo) {
|
||||
>
|
||||
@Icon("book-open", "h-5 w-5")
|
||||
</a>
|
||||
} else if item.FormatGroup == "audiobook" {
|
||||
<a
|
||||
href={ "/readers/" + item.MediaItemID }
|
||||
class="book-card-action-btn"
|
||||
aria-label={ "Listen to " + item.Title }
|
||||
title="Listen"
|
||||
>
|
||||
@Icon("headphones", "h-5 w-5")
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ "/readers/" + item.MediaItemID }
|
||||
@@ -285,7 +306,7 @@ type BookStatsData struct {
|
||||
// itself through htmx when the body sees a `week-refresh` event
|
||||
// (dispatched by the library switcher), carrying the CURRENT
|
||||
// #library-select value, so the widget tracks the switched library.
|
||||
templ DashboardWeekWidget(week WeekStatsData) {
|
||||
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
|
||||
<details
|
||||
id="week-widget"
|
||||
class="card group"
|
||||
@@ -315,23 +336,39 @@ templ DashboardWeekWidget(week WeekStatsData) {
|
||||
</summary>
|
||||
<div class="px-4 md:px-6 pb-4 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Reading Time</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
|
||||
</div>
|
||||
if activeLibType == "audiobooks" {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Listening Time</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
|
||||
</div>
|
||||
} else {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Reading Time</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs mt-3" style="color: var(--text-secondary)">
|
||||
Synced from your reading devices
|
||||
|
||||
+407
-269
File diff suppressed because it is too large.
Load diff
@@ -168,6 +168,16 @@ templ Icon(name string, extra string) {
|
||||
<ellipse cx="12" cy="5" rx="8" ry="3"></ellipse>
|
||||
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"></path>
|
||||
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path>
|
||||
case "headphones":
|
||||
<path d="M4 14v-2a8 8 0 0 1 16 0v2"></path>
|
||||
<rect x="3" y="14" width="4" height="7" rx="1.5"></rect>
|
||||
<rect x="17" y="14" width="4" height="7" rx="1.5"></rect>
|
||||
case "comic":
|
||||
<path d="M4 4h16v12l-3 4H7l-3-4z"></path>
|
||||
<path d="M4 9h16M9 9v11"></path>
|
||||
case "manga":
|
||||
<path d="M4 4h16v13a3 3 0 0 1-3 3H4z"></path>
|
||||
<path d="M4 4v16M10 4h10v8"></path>
|
||||
default:
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
}
|
||||
|
||||
@@ -331,13 +331,28 @@ func Icon(name string, extra string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "headphones":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<path d=\"M4 14v-2a8 8 0 0 1 16 0v2\"></path> <rect x=\"3\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect> <rect x=\"17\" y=\"14\" width=\"4\" height=\"7\" rx=\"1.5\"></rect>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "comic":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<path d=\"M4 4h16v12l-3 4H7l-3-4z\"></path> <path d=\"M4 9h16M9 9v11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "manga":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<path d=\"M4 4h16v13a3 3 0 0 1-3 3H4z\"></path> <path d=\"M4 4v16M10 4h10v8\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
default:
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</svg>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
|
||||
<div class="stat-card">
|
||||
<span class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Current Position</span>
|
||||
<p class="text-sm font-medium" style="color: var(--text-primary)">
|
||||
if item.FormatGroup == "reflowable" {
|
||||
if item.FormatGroup == "audiobook" && item.DurationSeconds > 0 {
|
||||
{ fmtSecondsHMS(item.ProgressPercentage/100*item.DurationSeconds) + " / " + fmtSecondsHMS(item.DurationSeconds) }
|
||||
} else if item.FormatGroup == "reflowable" {
|
||||
if item.EstimatedPages > 0 {
|
||||
{ fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages) }
|
||||
} else {
|
||||
|
||||
+51
-41
@@ -159,35 +159,45 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.FormatGroup == "reflowable" {
|
||||
if item.FormatGroup == "audiobook" && item.DurationSeconds > 0 {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmtSecondsHMS(item.ProgressPercentage/100*item.DurationSeconds) + " / " + fmtSecondsHMS(item.DurationSeconds))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 76, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if item.FormatGroup == "reflowable" {
|
||||
if item.EstimatedPages > 0 {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 77, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage))
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 79, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 79, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 81, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages))
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 82, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 84, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -196,12 +206,12 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated)
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 88, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 90, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -209,12 +219,12 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon)
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 93, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 95, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -222,12 +232,12 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 94, Col: 98}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 96, Col: 98}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -248,12 +258,12 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 101, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 103, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -261,12 +271,12 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType)
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 102, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 104, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -288,12 +298,12 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 108, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 110, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -62,6 +62,22 @@ func isUserVisible(userID string, visibility []UserVisibilityData) bool {
|
||||
|
||||
// TotalMediaCount sums the MediaCount across the given libraries,
|
||||
// used to display the total next to the "All Libraries" option.
|
||||
// MediaBadgeIcon selects the cover-corner badge glyph per media group
|
||||
// (A2c): the per-type identifier on mixed grids. Books stay unbadged —
|
||||
// the shelf's default reads as a book without a stamp.
|
||||
func MediaBadgeIcon(formatGroup string) string {
|
||||
switch formatGroup {
|
||||
case "audiobook":
|
||||
return "headphones"
|
||||
case "comic":
|
||||
return "comic"
|
||||
case "manga":
|
||||
return "manga"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func TotalMediaCount(libs []LibraryData) int64 {
|
||||
var total int64
|
||||
for _, l := range libs {
|
||||
@@ -350,3 +366,19 @@ func hxValsFolderDelete(folderPath string) string {
|
||||
}
|
||||
return "js:" + string(b)
|
||||
}
|
||||
|
||||
// fmtSecondsHMS renders seconds as H:MM:SS (or M:SS under an hour) —
|
||||
// the audio progress display shape shared with the web player.
|
||||
func fmtSecondsHMS(seconds float64) string {
|
||||
s := int(seconds + 0.5)
|
||||
if s <= 0 {
|
||||
return "0:00"
|
||||
}
|
||||
h := s / 3600
|
||||
m := (s % 3600) / 60
|
||||
sec := s % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, sec)
|
||||
}
|
||||
+156
-10
@@ -18,7 +18,6 @@ import { showToast } from "./toast";
|
||||
type AudioChapter = { index: number; title: string; start: number; end: number };
|
||||
|
||||
const PROGRESS_INTERVAL_MS = 30_000;
|
||||
const PLAYING_THRESHOLD = 0.5; // seconds of audio that count as "playing"
|
||||
|
||||
interface AudioConfig {
|
||||
chapterEndpoint: string;
|
||||
@@ -53,14 +52,30 @@ Alpine.data("audioReader", () => ({
|
||||
playing: false,
|
||||
speed: Number(localStorage.getItem("audio_speed") || 1),
|
||||
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
|
||||
// Scrub guard: while dragging, timeupdate must not fight the handle
|
||||
scrubbing: false,
|
||||
scrubValue: 0,
|
||||
|
||||
// Bookmarks (A2d): time-positioned, with notes. Audio locator
|
||||
// convention: position = "audio:<seconds>" (chapter_number filled for
|
||||
// display); dedup + cross-device sync happen server-side.
|
||||
bookmarks: [] as Record<string, unknown>[],
|
||||
bookmarksOpen: false,
|
||||
bookmarkCaptureOpen: false,
|
||||
bookmarkNote: "",
|
||||
editingBookmarkId: "",
|
||||
editingNote: "",
|
||||
|
||||
// chapters are absolute seconds into the book; the scrubber spans the
|
||||
// whole book, not per-chapter
|
||||
bookDuration: 0,
|
||||
currentChapterIdx: 0,
|
||||
|
||||
// progress-push bookkeeping
|
||||
// progress-push bookkeeping: pendingElapsed accumulates REAL PLAYING
|
||||
// time via timeupdate deltas (pauses never count); pushed once per
|
||||
// 30s tick, then zeroed. prevTick is the last performance.now().
|
||||
pendingElapsed: 0,
|
||||
prevTick: 0,
|
||||
lastPush: Date.now(),
|
||||
pushTimer: undefined as ReturnType<typeof setInterval> | undefined,
|
||||
|
||||
@@ -96,6 +111,8 @@ Alpine.data("audioReader", () => ({
|
||||
this.restorePosition();
|
||||
// Periodic progress push (playing-only; pause/hide push too)
|
||||
this.startPushTimer();
|
||||
// Bookmarks list (time-located; notes optional)
|
||||
this.loadBookmarks();
|
||||
|
||||
// Media Session (lockscreen/notification transport).
|
||||
if ("mediaSession" in navigator) {
|
||||
@@ -114,19 +131,29 @@ Alpine.data("audioReader", () => ({
|
||||
|
||||
// Element events.
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
this.currentTime = audio.currentTime;
|
||||
if (!this.scrubbing) this.currentTime = audio.currentTime;
|
||||
this.tickChapter();
|
||||
this.pendingElapsed = Math.max(
|
||||
this.pendingElapsed,
|
||||
Math.round(audio.currentTime - this.sessionStart),
|
||||
);
|
||||
// Honest session accounting: only ACTUAL PLAYING time counts. The
|
||||
// prevSnapshot walk keeps internal seeks/crossfades from inflating
|
||||
// the sum; progress pushes take the delta since the last push.
|
||||
const now = performance.now();
|
||||
if (!audio.paused) {
|
||||
const span = now - this.prevTick;
|
||||
this.pendingElapsed += span;
|
||||
}
|
||||
this.prevTick = now;
|
||||
});
|
||||
audio.addEventListener("play", () => {
|
||||
this.playing = true;
|
||||
this.sessionStart = audio.currentTime;
|
||||
this.prevTick = performance.now();
|
||||
this.updateMediaSessionState();
|
||||
});
|
||||
audio.addEventListener("pause", () => {
|
||||
const now = performance.now();
|
||||
if (this.playing) {
|
||||
this.pendingElapsed += Math.max(0, now - this.prevTick);
|
||||
}
|
||||
this.prevTick = now;
|
||||
this.playing = false;
|
||||
this.pushProgress(true);
|
||||
this.updateMediaSessionState();
|
||||
@@ -267,6 +294,126 @@ Alpine.data("audioReader", () => ({
|
||||
localStorage.setItem("audio_skip", String(seconds));
|
||||
},
|
||||
|
||||
backToDetail() {
|
||||
this.pushProgress(true);
|
||||
window.location.href = `/media/${this.mediaItemId}`;
|
||||
},
|
||||
|
||||
// ---- bookmarks (A2d) ----
|
||||
|
||||
async loadBookmarks() {
|
||||
try {
|
||||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
// GET /bookmarks returns a bare array of rows.
|
||||
const all = (Array.isArray(data) ? data : []) as Record<string, unknown>[];
|
||||
this.bookmarks = all
|
||||
.filter((b) => String(b.position || "").startsWith("audio:"))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
this.bookmarkSeconds(String(a.position)) -
|
||||
this.bookmarkSeconds(String(b.position)),
|
||||
);
|
||||
} catch {
|
||||
// bookmarks are non-critical UI
|
||||
}
|
||||
},
|
||||
|
||||
bookmarkSeconds(position: string): number {
|
||||
const n = parseInt(String(position).slice("audio:".length), 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
},
|
||||
|
||||
toggleBookmarkCapture() {
|
||||
if (this.bookmarkCaptureOpen) {
|
||||
this.bookmarkCaptureOpen = false;
|
||||
return;
|
||||
}
|
||||
this.bookmarkNote = "";
|
||||
this.bookmarkCaptureOpen = true;
|
||||
},
|
||||
|
||||
async saveBookmark() {
|
||||
const audio = this.audio();
|
||||
if (!audio) return;
|
||||
const at = Math.round(audio.currentTime);
|
||||
const chapter = this.chapters[this.currentChapterIdx];
|
||||
const title = chapter
|
||||
? `${chapter.title || `Chapter ${chapter.index + 1}`} — ${fmtTime(at)}`
|
||||
: `Bookmark — ${fmtTime(at)}`;
|
||||
try {
|
||||
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks`, {
|
||||
method: "POST",
|
||||
headers: { ...getAuthHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
position: `audio:${at}`,
|
||||
notes: this.bookmarkNote.trim(),
|
||||
chapter_number: this.currentChapterIdx,
|
||||
origin: "web",
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error("save failed");
|
||||
this.bookmarkCaptureOpen = false;
|
||||
this.bookmarkNote = "";
|
||||
showToast(`Bookmark saved at ${fmtTime(at)}`, "success");
|
||||
await this.loadBookmarks();
|
||||
} catch {
|
||||
showToast("Could not save the bookmark", "error");
|
||||
}
|
||||
},
|
||||
|
||||
startEditNote(b: Record<string, unknown>) {
|
||||
this.editingBookmarkId = String(b.id || "");
|
||||
this.editingNote = String(b.notes || "");
|
||||
},
|
||||
|
||||
async saveNote() {
|
||||
const b = this.bookmarks.find(
|
||||
(x) => String(x.id) === this.editingBookmarkId,
|
||||
);
|
||||
if (!b) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/bookmarks/${this.editingBookmarkId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...getAuthHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: String(b.title || "Bookmark"),
|
||||
notes: this.editingNote,
|
||||
position: String(b.position || ""),
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error("save failed");
|
||||
this.editingBookmarkId = "";
|
||||
await this.loadBookmarks();
|
||||
} catch {
|
||||
showToast("Could not update the note", "error");
|
||||
}
|
||||
},
|
||||
|
||||
async deleteBookmark(b: Record<string, unknown>) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/bookmarks/${b.id}`,
|
||||
{ method: "DELETE", headers: getAuthHeaders() },
|
||||
);
|
||||
if (!resp.ok) throw new Error("delete failed");
|
||||
await this.loadBookmarks();
|
||||
} catch {
|
||||
showToast("Could not delete the bookmark", "error");
|
||||
}
|
||||
},
|
||||
|
||||
jumpToBookmark(b: Record<string, unknown>) {
|
||||
this.seekTo(this.bookmarkSeconds(String(b.position || "")));
|
||||
},
|
||||
|
||||
updateMediaSessionMetadata() {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
const ch = this.chapters[this.currentChapterIdx];
|
||||
@@ -297,13 +444,12 @@ Alpine.data("audioReader", () => ({
|
||||
offset = Math.max(0, Math.round(at - this.chapters[i].start));
|
||||
}
|
||||
}
|
||||
const playing = this.playing && audio.currentTime - this.sessionStart > PLAYING_THRESHOLD;
|
||||
const elapsed = Math.max(0, Math.round(this.pendingElapsed));
|
||||
const body: Record<string, unknown> = {
|
||||
percentage: this.chaptersDuration() ? at / this.chaptersDuration() : 0,
|
||||
chapter,
|
||||
audio_offset_seconds: offset,
|
||||
elapsed_seconds: playing || exit ? elapsed : 0,
|
||||
elapsed_seconds: elapsed >= 1 || (exit && this.playing) ? elapsed : 0,
|
||||
reading_mode: "audio",
|
||||
};
|
||||
this.pendingElapsed = 0;
|
||||
|
||||
@@ -86,6 +86,18 @@ function collectFormData(editorTags: string[]): Record<string, unknown> {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Narrators arrive as one comma-separated form field (mirrors
|
||||
// contributors); empty → drop the key so non-audio saves don't ship [].
|
||||
const narrStr = data.narrators as string;
|
||||
if (typeof narrStr === "string") {
|
||||
const narrators = narrStr
|
||||
.split(",")
|
||||
.map((n: string) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (narrators.length) data.narrators = narrators;
|
||||
else delete data.narrators;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -444,6 +444,11 @@ Alpine.data("bookshelf", () => ({
|
||||
await this.fetchFieldValues("series", input.value, "series-datalist");
|
||||
},
|
||||
|
||||
// Fetch narrator values for autocomplete (A2a)
|
||||
async fetchNarratorValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("narrator", input.value, "narrator-datalist");
|
||||
},
|
||||
|
||||
// Fetch language values for autocomplete
|
||||
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("language", input.value, "language-datalist");
|
||||
|
||||
Reference in new issue
Block a user