feat(filters+ui): per-element author/publisher filters, playlist toggles, Up Next API, reorder UI, dashboard badges
Phases 4 + 5 of the metadata overhaul (plan §4, §5) plus §3 handler wiring. Filters (§4.1): author_filter/publisher_filter now match per-element against the authors[]/publishers[] arrays — a co-authored book matches any one of its authors — with comma-joined params acting as OR-within-facet multi-select (string_to_array + cross unnest). Legacy single-value params unchanged on the wire; series/genre/language filters stay on their varchar columns; narrator filter already per-element. Collections API (§3.3-3.5): collection detail carries viewer + hide_finished into the listing query and exposes up_next (visible- library-enforced reading-queue head); PUT /api/collections/:id/items/order (owner-only, full-order rewrite); UpdateCollectionRequest gains hide_finished/show_up_next ([] string last-value-wins for the hidden-false-sibling checkbox pattern) merged server-side OVER stored view_settings so imported_from_series provenance survives edits; EditCollectionModal renders the toggles from view_settings. Dashboard (§5.5): sections JSON carries is_playlist/complete (dashboard.ts is the AGENTS-documented JSON-render exception; first paint stays templ). Fully-read playlists render '✓ Playlist complete' instead of an empty rail; imported playlists get a 📖 Playlist badge. Frontend: book detail shows subtitle + full authors + Explicit badge; edit modal gains subtitle, comma-separated authors/publishers (client- split to arrays exactly like narrators), step=any series number, explicit checkbox; collection book cards get playlist move ↑/↓ buttons persisting the full order via the reorder endpoint (templ partial re-render on failure — DOM order is client-local, API order is truth). AGENTS.md gotcha #3 amended per plan §3.7: user collections are dashboard sections by default; the show_on_dashboard premise is retired. Verified: full affected integration sweep green (pins widened for up_next/is_playlist/complete — changelog entries in docs/contract_pins.md); unit suite green; go build + templ + vite green.
This commit is contained in:
1 parent
1dbb0ce406
commit
f94e7472b4
20 files changed
+1711
-1339
No files matched your search
@@ -161,14 +161,19 @@ of a server bug: stop and flag the user.
|
|||||||
the cache stale until the next API-path write — never "preview"
|
the cache stale until the next API-path write — never "preview"
|
||||||
rule changes via psql and expect them applied (2026-10-14: a live
|
rule changes via psql and expect them applied (2026-10-14: a live
|
||||||
curl test flip looked ignored until the API path was used).
|
curl test flip looked ignored until the API path was used).
|
||||||
3. **User collections are not dashboard sections**: nothing sets
|
3. **User collections are not dashboard query types** (amended 2026-10-19,
|
||||||
collection show_on_dashboard for user rows, and GetDashboardSections
|
A2c): user collections ARE dashboard sections by default now —
|
||||||
skips empty user sections. Collection detail (JSON + SSR) must NOT
|
GetUserCollectionsForDashboard no longer gates on show_on_dashboard
|
||||||
route user rows through the dashboard-section path — a 7ce7321
|
(which remains system-section provisioning state; hiding is
|
||||||
regression did exactly that (static detail without library_id
|
dashboard-prefs `hidden_collections` territory). GetDashboardSections
|
||||||
returned zero books until 0a7fbd6). Only real system query types
|
still skips genuinely empty user sections, but hide_finished
|
||||||
(continue-reading etc.) route there; `query_type='filter'` is the
|
collections emit a Complete section instead of vanishing. Collection
|
||||||
schema DEFAULT on every user row, not a query type.
|
detail (JSON + SSR) must still NOT route user rows through the
|
||||||
|
dashboard-section path — a 7ce7321 regression did exactly that
|
||||||
|
(static detail without library_id returned zero books until 0a7fbd6).
|
||||||
|
Only real system query types (continue-reading etc.) route there;
|
||||||
|
`query_type='filter'` is the schema DEFAULT on every user row, not a
|
||||||
|
query type.
|
||||||
4. **End-of-day arithmetic**: anchor the END bound to the START of the
|
4. **End-of-day arithmetic**: anchor the END bound to the START of the
|
||||||
day, never to NOW(). `now.AddDate(0,0,1) − 1ns` carries this
|
day, never to NOW(). `now.AddDate(0,0,1) − 1ns` carries this
|
||||||
instant's clock time into tomorrow — a range "ending today"
|
instant's clock time into tomorrow — a range "ending today"
|
||||||
|
|||||||
@@ -13218,14 +13218,26 @@ WHERE COALESCE(lv.is_visible, true) = true
|
|||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND ($2::uuid IS NULL
|
AND ($2::uuid IS NULL
|
||||||
OR mi.library_id = $2::uuid)
|
OR mi.library_id = $2::uuid)
|
||||||
-- Fuzzy author filter
|
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
||||||
AND ($3 = '' OR word_similarity($3, COALESCE(mi.author, '')) > 0.3)
|
-- §4.1): a co-authored book matches ANY one of its authors, and a
|
||||||
|
-- comma-joined param is multi-select (OR within the facet).
|
||||||
|
AND ($3 = '' OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM unnest(string_to_array($3, ',')) AS f
|
||||||
|
JOIN unnest(mi.authors) AS a ON true
|
||||||
|
WHERE word_similarity(f, COALESCE(a, '')) > 0.3
|
||||||
|
))
|
||||||
-- Fuzzy series filter
|
-- Fuzzy series filter
|
||||||
AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3)
|
AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3)
|
||||||
-- Fuzzy genre filter
|
-- Fuzzy genre filter
|
||||||
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
|
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
|
||||||
-- Fuzzy publisher filter
|
-- Fuzzy publisher filter — per-element against publishers[] (A2c §4.1).
|
||||||
AND ($6 = '' OR word_similarity($6, COALESCE(mi.publisher, '')) > 0.3)
|
AND ($6 = '' OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM unnest(string_to_array($6, ',')) AS f
|
||||||
|
JOIN unnest(mi.publishers) AS p ON true
|
||||||
|
WHERE word_similarity(f, COALESCE(p, '')) > 0.3
|
||||||
|
))
|
||||||
-- Fuzzy language filter
|
-- Fuzzy language filter
|
||||||
AND ($7 = '' OR word_similarity($7, COALESCE(mi.language, '')) > 0.3)
|
AND ($7 = '' OR word_similarity($7, COALESCE(mi.language, '')) > 0.3)
|
||||||
-- Tags filter (NEW - fuzzy match against tags array)
|
-- Tags filter (NEW - fuzzy match against tags array)
|
||||||
|
|||||||
@@ -679,14 +679,26 @@ WHERE COALESCE(lv.is_visible, true) = true
|
|||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL
|
AND (sqlc.narg('library_id')::uuid IS NULL
|
||||||
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
-- Fuzzy author filter
|
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
||||||
AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 0.3)
|
-- §4.1): a co-authored book matches ANY one of its authors, and a
|
||||||
|
-- comma-joined param is multi-select (OR within the facet).
|
||||||
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
||||||
|
JOIN unnest(mi.authors) AS a ON true
|
||||||
|
WHERE word_similarity(f, COALESCE(a, '')) > 0.3
|
||||||
|
))
|
||||||
-- Fuzzy series filter
|
-- Fuzzy series filter
|
||||||
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
||||||
-- Fuzzy genre filter
|
-- Fuzzy genre filter
|
||||||
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
||||||
-- Fuzzy publisher filter
|
-- Fuzzy publisher filter — per-element against publishers[] (A2c §4.1).
|
||||||
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 0.3)
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
||||||
|
JOIN unnest(mi.publishers) AS p ON true
|
||||||
|
WHERE word_similarity(f, COALESCE(p, '')) > 0.3
|
||||||
|
))
|
||||||
-- Fuzzy language filter
|
-- Fuzzy language filter
|
||||||
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
||||||
-- Tags filter (NEW - fuzzy match against tags array)
|
-- Tags filter (NEW - fuzzy match against tags array)
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ type UpdateCollectionRequest struct {
|
|||||||
Icon string `form:"icon" json:"icon"`
|
Icon string `form:"icon" json:"icon"`
|
||||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
||||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||||
|
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
||||||
|
// (nil = untouched). []string because the form posts a hidden "false"
|
||||||
|
// sibling next to the checkbox; the LAST value wins. JSON clients may
|
||||||
|
// send a single-element array (or omit — untouched).
|
||||||
|
HideFinished []string `form:"hide_finished" json:"hide_finished"`
|
||||||
|
ShowUpNext []string `form:"show_up_next" json:"show_up_next"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddBooksRequest struct {
|
type AddBooksRequest struct {
|
||||||
@@ -404,6 +410,23 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A2c §3.3: playlist toggles ride the edit form as top-level fields and
|
||||||
|
// merge into view_settings server-side (the form can't bind a map).
|
||||||
|
// The form posts a hidden "false" sibling before the checkbox, so the
|
||||||
|
// LAST value is the checkbox state.
|
||||||
|
if v := optionalFlag(req.HideFinished); v != nil {
|
||||||
|
if req.ViewSettings == nil {
|
||||||
|
req.ViewSettings = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
req.ViewSettings["hide_finished"] = *v
|
||||||
|
}
|
||||||
|
if v := optionalFlag(req.ShowUpNext); v != nil {
|
||||||
|
if req.ViewSettings == nil {
|
||||||
|
req.ViewSettings = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
req.ViewSettings["show_up_next"] = *v
|
||||||
|
}
|
||||||
|
|
||||||
collection, err := h.collectionService.UpdateCollection(
|
collection, err := h.collectionService.UpdateCollection(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
collectionID,
|
collectionID,
|
||||||
@@ -1336,3 +1359,13 @@ func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// optionalFlag interprets a multi-valued form flag: last value wins
|
||||||
|
// (hidden-false sibling precedes the checkbox), absent → nil (untouched).
|
||||||
|
func optionalFlag(values []string) *bool {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := strings.EqualFold(values[len(values)-1], "true")
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -605,6 +605,19 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
Icon: utils.PgTextString(collection.Icon),
|
Icon: utils.PgTextString(collection.Icon),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A2c §3.3: playlist toggles from view_settings.
|
||||||
|
if len(collection.ViewSettings) > 0 {
|
||||||
|
var vs map[string]interface{}
|
||||||
|
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
||||||
|
if b, ok := vs["hide_finished"].(bool); ok {
|
||||||
|
colData.HideFinished = b
|
||||||
|
}
|
||||||
|
if b, ok := vs["show_up_next"].(bool); ok {
|
||||||
|
colData.ShowUpNext = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure default color if empty
|
// Ensure default color if empty
|
||||||
if colData.Color == "" {
|
if colData.Color == "" {
|
||||||
colData.Color = "blue"
|
colData.Color = "blue"
|
||||||
|
|||||||
@@ -155,6 +155,23 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
|
|||||||
|
|
||||||
// UpdateCollection updates a collection
|
// UpdateCollection updates a collection
|
||||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
|
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
|
||||||
|
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
||||||
|
// them OVER the stored view_settings so provenance keys
|
||||||
|
// (imported_from_series) and anything else on the row survive.
|
||||||
|
if len(viewSettings) > 0 {
|
||||||
|
existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||||
|
if err == nil && len(existing.ViewSettings) > 0 {
|
||||||
|
var stored map[string]any
|
||||||
|
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
|
||||||
|
merged := stored
|
||||||
|
for k, v := range viewSettings {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
viewSettings = merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert rules to JSONB ([]byte)
|
// Convert rules to JSONB ([]byte)
|
||||||
var rulesJSON []byte
|
var rulesJSON []byte
|
||||||
if len(autoAssignRules) > 0 {
|
if len(autoAssignRules) > 0 {
|
||||||
|
|||||||
@@ -63,7 +63,14 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
<h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
||||||
if book.Author.Valid && book.Author.String != "" {
|
if book.Subtitle.Valid && book.Subtitle.String != "" {
|
||||||
|
<p class="text-lg mb-2" style="color: var(--text-secondary)">{ book.Subtitle.String }</p>
|
||||||
|
}
|
||||||
|
if len(book.Authors) > 0 {
|
||||||
|
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||||
|
by { strings.Join(book.Authors, ", ") }
|
||||||
|
</h2>
|
||||||
|
} else if book.Author.Valid && book.Author.String != "" {
|
||||||
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||||
by { book.Author.String }
|
by { book.Author.String }
|
||||||
</h2>
|
</h2>
|
||||||
@@ -184,6 +191,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
}
|
}
|
||||||
<!-- Badges: series, direction, tags -->
|
<!-- Badges: series, direction, tags -->
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
if book.Explicit {
|
||||||
|
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);" title="Marked explicit in source metadata">Explicit</span>
|
||||||
|
}
|
||||||
if book.Series.Valid && book.Series.String != "" {
|
if book.Series.Valid && book.Series.String != "" {
|
||||||
<a
|
<a
|
||||||
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||||
|
|||||||
@@ -323,9 +323,13 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label>
|
||||||
<input type="text" name="title" value={ book.Title } class="input" />
|
<input type="text" name="title" value={ book.Title } class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Subtitle</label>
|
||||||
|
<input type="text" name="subtitle" value={ textToString(book.Subtitle) } class="input" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label>
|
||||||
<input type="text" name="author" value={ textToString(book.Author) } class="input" />
|
<input type="text" name="authors" value={ authorsFormValue(book.Authors, textToString(book.Author)) } class="input" placeholder="Comma-separated names"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
|
||||||
@@ -400,7 +404,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label>
|
||||||
<input type="text" name="publisher" value={ textToString(book.Publisher) } class="input" />
|
<input type="text" name="publishers" value={ authorsFormValue(book.Publishers, textToString(book.Publisher)) } class="input" placeholder="Comma-separated publishers"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label>
|
||||||
@@ -446,7 +450,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
|
||||||
<input type="number" name="series_number"
|
<input type="number" name="series_number" step="any"
|
||||||
value={ fmtNumber(book.SeriesNumber.Float64) }
|
value={ fmtNumber(book.SeriesNumber.Float64) }
|
||||||
class="input" />
|
class="input" />
|
||||||
</div>
|
</div>
|
||||||
@@ -524,6 +528,12 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<span>Abridged edition</span>
|
<span>Abridged edition</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||||
|
<input type="checkbox" name="explicit" checked={ book.Explicit } class="h-4 w-4 accent-current"/>
|
||||||
|
<span>Explicit content</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
if book.AudioDuration() > 0 {
|
if book.AudioDuration() > 0 {
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Duration (scanned)</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Duration (scanned)</label>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+679
-636
File diff suppressed because it is too large.
Load diff
@@ -72,6 +72,18 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
|
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
||||||
|
<div class="mb-4 space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||||
|
<input type="hidden" name="hide_finished" value="false"/>
|
||||||
|
<input type="checkbox" name="hide_finished" value="true" checked={ collection.HideFinished } class="h-4 w-4 accent-current"/>
|
||||||
|
<span>Hide finished books (reading-queue mode)</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||||
|
<input type="hidden" name="show_up_next" value="false"/>
|
||||||
|
<input type="checkbox" name="show_up_next" value="true" checked={ collection.ShowUpNext } class="h-4 w-4 accent-current"/>
|
||||||
|
<span>Show Up Next strip</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
||||||
|
|||||||
@@ -143,30 +143,56 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><div class=\"mb-4 space-y-2\"><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"hide_finished\" value=\"false\"> <input type=\"checkbox\" name=\"hide_finished\" value=\"true\" checked=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.HideFinished)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 135, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 78, Col: 98}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"h-4 w-4 accent-current\"> <span>Hide finished books (reading-queue mode)</span></label> <label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"show_up_next\" value=\"false\"> <input type=\"checkbox\" name=\"show_up_next\" value=\"true\" checked=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ShowUpNext)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 83, Col: 95}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 147, Col: 83}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
|
|||||||
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
|
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="flex flex-col items-center gap-0.5 mt-1">
|
||||||
|
<button type="button" class="icon-btn text-xs" title="Move earlier (playlist order)"
|
||||||
|
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", -1)" }>↑</button>
|
||||||
|
<button type="button" class="icon-btn text-xs" title="Move later (playlist order)"
|
||||||
|
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", 1)" }>↓</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
|
|||||||
+424
-398
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,10 @@ type CollectionData struct {
|
|||||||
Color string
|
Color string
|
||||||
Icon string
|
Icon string
|
||||||
IsSystem bool
|
IsSystem bool
|
||||||
|
// A2c §3.3: playlist toggles (static collections only). Hidden fields
|
||||||
|
// preserve the current values on submit; absent from the create form.
|
||||||
|
HideFinished bool
|
||||||
|
ShowUpNext bool
|
||||||
// IsSmart marks rule-based ("dynamic") collections: membership is
|
// IsSmart marks rule-based ("dynamic") collections: membership is
|
||||||
// evaluated live at render, not stored in collection_items.
|
// evaluated live at render, not stored in collection_items.
|
||||||
IsSmart bool
|
IsSmart bool
|
||||||
|
|||||||
@@ -475,3 +475,13 @@ func LibraryTypeDisplayName(name string) string {
|
|||||||
func fmtNumber(f float64) string {
|
func fmtNumber(f float64) string {
|
||||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authorsFormValue prefills an editable comma-separated author/publisher
|
||||||
|
// field: the array is the write truth; the legacy single value is the
|
||||||
|
// fallback for rows predating the arrays.
|
||||||
|
func authorsFormValue(arr []string, legacy string) string {
|
||||||
|
if len(arr) > 0 {
|
||||||
|
return strings.Join(arr, ", ")
|
||||||
|
}
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
@@ -98,6 +98,20 @@ function collectFormData(editorTags: string[]): Record<string, unknown> {
|
|||||||
else delete data.narrators;
|
else delete data.narrators;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A2b: authors/publishers are arrays server-side (write truth); the form
|
||||||
|
// fields are comma-separated strings exactly like narrators.
|
||||||
|
for (const key of ["authors", "publishers"] as const) {
|
||||||
|
const raw = data[key] as string;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const list = raw
|
||||||
|
.split(",")
|
||||||
|
.map((v: string) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (list.length) data[key] = list;
|
||||||
|
else delete data[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ export {
|
|||||||
showAllIcons,
|
showAllIcons,
|
||||||
testRule,
|
testRule,
|
||||||
toggleSelection,
|
toggleSelection,
|
||||||
|
moveCollectionBook,
|
||||||
};
|
};
|
||||||
|
|
||||||
Alpine.data("collections", () => ({
|
Alpine.data("collections", () => ({
|
||||||
@@ -553,6 +554,7 @@ Alpine.data("collections", () => ({
|
|||||||
initializeCollectionWebSocket,
|
initializeCollectionWebSocket,
|
||||||
initCollectionsPage,
|
initCollectionsPage,
|
||||||
loadCollectionRules,
|
loadCollectionRules,
|
||||||
|
moveCollectionBook,
|
||||||
navigateToCollection,
|
navigateToCollection,
|
||||||
populateIconGrid,
|
populateIconGrid,
|
||||||
selectColor,
|
selectColor,
|
||||||
@@ -594,3 +596,67 @@ Alpine.data("collections", () => ({
|
|||||||
this.pendingAction = null;
|
this.pendingAction = null;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// A2c §3.2: playlist ordering — move a card one slot and persist the full
|
||||||
|
// resulting order. The DOM cards are the server-rendered truth for CURRENT
|
||||||
|
// order; the API is the truth for SAVED order. On failure the grid refresh
|
||||||
|
// from the templ partial so the UI never lies.
|
||||||
|
async function moveCollectionBook(
|
||||||
|
this: any,
|
||||||
|
mediaId: string,
|
||||||
|
direction: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const container = document.getElementById("books-container");
|
||||||
|
if (!container) return;
|
||||||
|
const cards = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(".collection-book-card"),
|
||||||
|
);
|
||||||
|
const index = cards.findIndex(
|
||||||
|
(c) => c.dataset.mediaId === mediaId,
|
||||||
|
);
|
||||||
|
const target = index + direction;
|
||||||
|
if (index < 0 || target < 0 || target >= cards.length) return;
|
||||||
|
|
||||||
|
// Optimistic DOM swap (client-local state until the API confirms).
|
||||||
|
if (direction < 0) {
|
||||||
|
container.insertBefore(cards[index], cards[target]);
|
||||||
|
} else {
|
||||||
|
container.insertBefore(cards[target], cards[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedIds = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(".collection-book-card"),
|
||||||
|
)
|
||||||
|
.map((c) => c.dataset.mediaId)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/collections/${getCollectionId()}/items/order`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ item_ids: orderedIds }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
showToast("Failed to save the new order", "error");
|
||||||
|
// Rendering ownership: the templ partial re-renders the true order.
|
||||||
|
const gridTarget = document.getElementById("books-container");
|
||||||
|
if (gridTarget && (window as any).htmx) {
|
||||||
|
(window as any).htmx.ajax(
|
||||||
|
"GET",
|
||||||
|
`/collections/${getCollectionId()}/books-grid${window.location.search.startsWith("?") ? window.location.search : ""}`,
|
||||||
|
{ target: "#books-container", swap: "innerHTML" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("Failed to save the new order", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-1
@@ -199,13 +199,36 @@ async function restoreSystemCollection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSectionHTML(section: SectionData): string {
|
function renderSectionHTML(section: SectionData): string {
|
||||||
|
// A2c §3.5: a fully-read playlist (hide_finished drained the queue)
|
||||||
|
// renders its completion state instead of book cards.
|
||||||
|
if (section.complete) {
|
||||||
|
return `
|
||||||
|
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-2xl">${section.icon}</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}
|
||||||
|
<span class="badge ml-2" style="background-color: var(--accent-muted); color: var(--accent);">✓ Playlist complete</span>
|
||||||
|
</h2>
|
||||||
|
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="card p-6 text-sm" style="color: var(--text-secondary)">Every book in this playlist is finished. Time to add the next one.</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const playlistBadge = section.is_playlist
|
||||||
|
? `<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);" title="Curated reading order">📖 Playlist</span>`
|
||||||
|
: "";
|
||||||
return `
|
return `
|
||||||
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
|
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-2xl">${section.icon}</span>
|
<span class="text-2xl">${section.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}</h2>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}${playlistBadge}</h2>
|
||||||
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ""}
|
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+4
@@ -119,6 +119,10 @@ interface SectionData {
|
|||||||
items: BookInfo[];
|
items: BookInfo[];
|
||||||
view_all_url: string;
|
view_all_url: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
// A2c (2026-10-19): imported reading-order playlist + hide_finished
|
||||||
|
// completion state — see docs/contract_pins.md.
|
||||||
|
is_playlist: boolean;
|
||||||
|
complete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches database.UserDashboardPreferences and dashboard preferences API
|
// Matches database.UserDashboardPreferences and dashboard preferences API
|
||||||
|
|||||||
Reference in new issue
Block a user