feat(web): templ partials replace the last UI twins (collections grid, conflicts list)

Both pages had a templ render (initial load) and a TypeScript twin
(innerHTML rebuild on refresh) of the same fragment. The twins are
deleted; the server-rendered markup is now the only markup.

- collections: the library switcher swaps in the new books-grid partial
  (GET /collections/:id/books-grid) instead of re-rendering cards from
  the /api/collections JSON. Book loading is extracted into
  Config.loadCollectionBooks, shared verbatim by the page and the
  partial (smart / dashboard-query / manual branches, family-lens
  scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot
  drift.
- conflicts: resolve/dismiss refreshes swap the new list partial
  (GET /conflicts/list) with the stats bar as an out-of-band swap,
  instead of the TS twin — which rendered a degraded card (no
  checkboxes, no timestamps), pointed at a window.showResolveModal that
  does not exist, and updated stats via element ids the SSR never had.
- shims.d.ts: window.htmx typing (ajax/process) for TS callers.

Visibility note: the books-grid partial inherits its family-lens
scoping from the shared loader (identical to the detail page); a
dedicated hidden-library pin test for the partial route is still worth
adding in the next test pass.
This commit is contained in:
John O'Keefe committed 2026-10-18 12:28:25 -04:00
1 parent 1fdf306f2d
commit f1844591b4
9 files changed
+1078 -889

No files matched your search

+67 -89
View File
@@ -660,18 +660,19 @@ func registerFrontendRoutes(cfg *Config) {
}) })
// Collection detail page (works for both system and user collections) // Collection detail page (works for both system and user collections)
// Collection detail page. Book loading lives in loadCollectionBooks —
// shared verbatim with the books-grid partial below so the SSR page and
// the partial can never drift (rendering-ownership rule).
frontendProtected.GET("/collections/:id", func(c *echo.Context) error { frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
if err != nil { if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error") return renderErrorPage(c, "Error loading user", "user_load_error")
} }
// Parse collection ID from URL
collectionID := c.Param("id") collectionID := c.Param("id")
collUUID, err := uuid.Parse(collectionID) collUUID, err := uuid.Parse(collectionID)
if err != nil { if err != nil {
return renderErrorPage(c, "Invalid collection ID", "invalid_id") return renderErrorPage(c, "Invalid collection ID", "invalid_id")
} }
// Fetch collection details
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil { if err != nil {
if err.Error() == "no rows in result set" { if err.Error() == "no rows in result set" {
@@ -679,102 +680,56 @@ func registerFrontendRoutes(cfg *Config) {
} }
return renderErrorPage(c, "Error loading collection", "collection_load_error") return renderErrorPage(c, "Error loading collection", "collection_load_error")
} }
// Fetch books in collection books, libRes, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
userUUID, _ := uuid.Parse(user.ID) if errCode != "" {
var books []handlers.BookInfo msg := map[string]string{
"books_load_error": "Error loading books",
libRes := resolveLibrary(c, cfg, user.ID) "invalid_library_id": "Invalid library ID",
libraryID := libRes.LibraryID "invalid_id": "Invalid collection ID",
}[errCode]
var libScope pgtype.UUID return renderErrorPage(c, msg, errCode)
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
colService := cfg.CollectionHandler.CollectionService()
isSmart := colService.IsSmartCollection(collection.AutoAssignRules)
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
})
if evalErr != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
books = handlers.BookCardsFromMediaItems(matches, libTypes)
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
if secErr != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
for _, section := range sections {
if section.CollectionID.String() == collectionID {
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
break
}
}
} else {
if libraryID != "" && !libRes.IsAll {
libUUID, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
}
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if collErr != nil {
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
} else {
// The query filters excluded rows in SQL.
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
}
}
// Build collection data
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
BookCount: len(books),
} }
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
var buf bytes.Buffer var buf bytes.Buffer
err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).Render(c.Request().Context(), &buf) err = templates.CollectionDetail(user, colData, books, libRes.LibraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
} }
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
}) })
// Books-grid partial: the collection detail page's #books-container
// content. The library switcher swaps this in (htmx.ajax) instead of
// re-rendering the grid client-side from JSON. Family-lens scoped via
// loadCollectionBooks — same resolution rules as the detail page.
frontendProtected.GET("/collections/:id/books-grid", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
collectionID := c.Param("id")
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return c.HTML(http.StatusBadRequest, "<div>Invalid collection ID</div>")
}
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusNotFound, "<div>Collection not found</div>")
}
books, _, isSmart, errCode := cfg.loadCollectionBooks(c, user, collection, collectionID)
if errCode != "" {
return c.HTML(http.StatusBadRequest, "<div>Error loading books</div>")
}
colData := collectionDataFromRow(collection, collectionID, isSmart, len(books))
var buf bytes.Buffer
if renderErr := templates.CollectionBooksGrid(colData, books).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Week-widget fragment: the collapsible "This Week" strip re-fetches // Week-widget fragment: the collapsible "This Week" strip re-fetches
// itself here (htmx) whenever the library switcher dispatches // itself here (htmx) whenever the library switcher dispatches
// week-refresh, carrying the current #library-select value. Same // week-refresh, carrying the current #library-select value. Same
@@ -945,6 +900,29 @@ func registerFrontendRoutes(cfg *Config) {
} }
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
}) })
// Conflicts list partial: fresh list plus the stats bar as an
// out-of-band swap. The client refreshes through this instead of
// re-rendering conflict cards from JSON (rendering-ownership rule:
// templ renders, JS only swaps).
frontendProtected.GET("/conflicts/list", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
conflictsData, total, unresolved, dataErr := cfg.ConflictHandler.GetConflictsData(c)
if dataErr != nil {
log.Printf("GetConflictsData failed: %v", dataErr)
conflictsData = []handlers.ConflictDetailResponse{}
total = 0
unresolved = 0
}
var buf bytes.Buffer
if renderErr := templates.ConflictsRefreshFragment(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Analytics page → /stats (the rebuilt reading-statistics page; // Analytics page → /stats (the rebuilt reading-statistics page;
// nobody bookmarks orphan pages twice). // nobody bookmarks orphan pages twice).
+105
View File
@@ -10,6 +10,8 @@ import (
"time" "time"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"bookhoard/templates" "bookhoard/templates"
@@ -257,3 +259,106 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
} }
return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU)) return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU))
} }
// collectionDataFromRow builds the template collection payload shared by
// the collection detail page and its books-grid partial.
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
return templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
BookCount: bookCount,
}
}
// loadCollectionBooks resolves the books shown for a collection: smart
// evaluation, a dashboard query section, or manual membership — scoped to
// the requesting user's visible libraries (family-lens) exactly like the
// collection detail page. Shared by the detail page and the books-grid
// partial route so the two can never drift.
// errCode is "" on success, otherwise a renderErrorPage code.
func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, collection database.Collections, collectionID string) (books []handlers.BookInfo, libRes LibraryResolution, isSmart bool, errCode string) {
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return nil, libRes, isSmart, "invalid_id"
}
userUUID, _ := uuid.Parse(user.ID)
libRes = resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
var libScope pgtype.UUID
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
colService := cfg.CollectionHandler.CollectionService()
isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
})
if evalErr != nil {
return nil, libRes, isSmart, "books_load_error"
}
books = handlers.BookCardsFromMediaItems(matches, libTypes)
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
if secErr != nil {
return nil, libRes, isSmart, "books_load_error"
}
for _, section := range sections {
if section.CollectionID.String() == collectionID {
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
break
}
}
} else {
if libraryID != "" && !libRes.IsAll {
libUUID, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return nil, libRes, isSmart, "invalid_library_id"
}
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if collErr != nil {
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
} else {
// The query filters excluded rows in SQL.
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
}
}
return books, libRes, isSmart, ""
}
+71 -62
View File
@@ -14,6 +14,76 @@ func pluralizeBooks(n int) string {
return "books" return "books"
} }
// CollectionBooksGrid renders the content of #books-container: the
// collection's book cards, or the empty state. Shared by the collection
// detail page and the /collections/:id/books-grid partial route (the
// library switcher refreshes the grid through it — rendering ownership:
// templ renders, JS only swaps).
templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) {
if len(books) == 0 {
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
}
for _, book := range books {
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
<div class="flex gap-4">
if !collection.IsSystem && !collection.IsSmart {
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
class="w-5 h-5"
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
/>
</label>
</div>
}
<div class="flex-1 min-w-0">
<a href={ "/media/" + book.MediaItemID }>
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
{ book.Title }
</h3>
</a>
if book.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
by { book.Author }
</p>
}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href={ "/media/" + book.MediaItemID }>
if book.CoverImagePath != "" {
<img
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</a>
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
}
</div>
}
}
templ Collection(user User, collections []CollectionData, errorMessage string) { templ Collection(user User, collections []CollectionData, errorMessage string) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -210,68 +280,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div> </div>
</div> </div>
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
if len(books) == 0 { @CollectionBooksGrid(collection, books)
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
}
for _, book := range books {
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
<div class="flex gap-4">
if !collection.IsSystem && !collection.IsSmart {
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
class="w-5 h-5"
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
/>
</label>
</div>
}
<div class="flex-1 min-w-0">
<a href={ "/media/" + book.MediaItemID }>
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
{ book.Title }
</h3>
</a>
if book.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
by { book.Author }
</p>
}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href={ "/media/" + book.MediaItemID }>
if book.CoverImagePath != "" {
<img
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</a>
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
}
</div>
}
</div> </div>
</div> </div>
if !collection.IsSystem && !collection.IsSmart { if !collection.IsSystem && !collection.IsSmart {
File diff suppressed because it is too large. Load diff
+115 -90
View File
@@ -2,96 +2,10 @@ package templates
import "bookhoard/internal/handlers" import "bookhoard/internal/handlers"
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) { // ConflictsListContent renders the inner content of #conflicts-list:
<!DOCTYPE html> // bulk-select controls, the empty state, or the conflict cards. Shared by
<html lang="en"> // the conflicts page and the /conflicts/list partial route.
<head> templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) {
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sync Conflicts - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="conflicts" class={ "theme-" + user.Theme }>
@Header(user, "/conflicts")
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="flex justify-between items-center gap-4 flex-wrap">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Conflicts</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
</div>
<div class="flex space-x-3">
<select
id="status-filter"
onchange="filterConflicts()"
class="input"
>
<option value="unresolved">Unresolved Only</option>
<option value="all">All Conflicts</option>
<option value="resolved">Resolved Only</option>
</select>
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button>
</div>
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
</div>
</div>
<div id="bulk-actions" class="mt-4 hidden">
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center space-x-4">
<span class="text-sm font-semibold" style="color: var(--text-primary);">
<span id="selected-count">0</span> conflicts selected
</span>
<select
id="bulk-strategy"
class="input w-auto"
>
<option value="most_recent">Most Recent</option>
<option value="highest_progress">Highest Progress</option>
</select>
</div>
<div class="flex space-x-2">
<button @click="bulkResolve()" class="btn btn-primary text-sm">
@Icon("check", "h-4 w-4")
Resolve Selected
</button>
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
Dismiss Selected
</button>
</div>
</div>
</div>
</div>
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="loading-spinner mx-auto mb-4"></div>
<p>Loading conflicts...</p>
</div>
<div id="empty-state" class="hidden card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
<div id="conflicts-list" class="space-y-4">
<div id="select-all-container" class="mb-4 hidden"> <div id="select-all-container" class="mb-4 hidden">
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/> <input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
@@ -137,6 +51,117 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</div> </div>
</div> </div>
} }
}
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
templ ConflictStatsBar(total int, unresolved int) {
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
</div>
<div class="stat-card text-center py-3">
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
</div>
}
// ConflictsRefreshFragment is the /conflicts/list partial response: a
// fresh list plus the stats bar as an out-of-band swap. The client swaps
// this instead of re-rendering conflict cards from JSON (rendering
// ownership: templ renders, JS only swaps).
templ ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) {
<div id="conflicts-list" class="space-y-4">
@ConflictsListContent(user, conflicts)
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg" hx-swap-oob="true">
@ConflictStatsBar(total, unresolved)
</div>
}
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sync Conflicts - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body x-data="conflicts" class={ "theme-" + user.Theme }>
@Header(user, "/conflicts")
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="flex justify-between items-center gap-4 flex-wrap">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Conflicts</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
</div>
<div class="flex space-x-3">
<select
id="status-filter"
onchange="filterConflicts()"
class="input"
>
<option value="unresolved">Unresolved Only</option>
<option value="all">All Conflicts</option>
<option value="resolved">Resolved Only</option>
</select>
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button>
</div>
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
@ConflictStatsBar(total, unresolved)
</div>
<div id="bulk-actions" class="mt-4 hidden">
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center space-x-4">
<span class="text-sm font-semibold" style="color: var(--text-primary);">
<span id="selected-count">0</span> conflicts selected
</span>
<select
id="bulk-strategy"
class="input w-auto"
>
<option value="most_recent">Most Recent</option>
<option value="highest_progress">Highest Progress</option>
</select>
</div>
<div class="flex space-x-2">
<button @click="bulkResolve()" class="btn btn-primary text-sm">
@Icon("check", "h-4 w-4")
Resolve Selected
</button>
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
Dismiss Selected
</button>
</div>
</div>
</div>
</div>
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="loading-spinner mx-auto mb-4"></div>
<p>Loading conflicts...</p>
</div>
<div id="empty-state" class="hidden card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
<div id="conflicts-list" class="space-y-4">
@ConflictsListContent(user, conflicts)
</div> </div>
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);"> <div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
+261 -138
View File
@@ -10,7 +10,10 @@ import templruntime "github.com/a-h/templ/runtime"
import "bookhoard/internal/handlers" import "bookhoard/internal/handlers"
func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) templ.Component { // ConflictsListContent renders the inner content of #conflicts-list:
// bulk-select controls, the empty state, or the conflict cards. Shared by
// the conflicts page and the /conflicts/list partial route.
func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -31,105 +34,12 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" onchange=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"conflicts\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/conflicts").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" onchange=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\"><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 45, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 53, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div></div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"bulkResolve()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check", "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, 9, "Resolve Selected</button> <button @click=\"bulkDismiss()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\"><div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" onchange=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(conflicts) == 0 { if len(conflicts) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -137,39 +47,39 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
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, 12, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
for _, conflict := range conflicts { for _, conflict := range conflicts {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"conflict-item card p-6\" data-conflict-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 111, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
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, 14, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"")
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_Var3 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 117, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 31, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
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, "\" onchange=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" onchange=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -177,83 +87,296 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
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, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 125, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 39, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
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, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 40, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
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, 18, "</p></div></div></div><button @click=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div></div></div><button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")") templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 130, Col: 73} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 44, Col: 73}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
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, 19, "\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone)) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 133, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 47, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
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, 20, " ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if conflict.ResolvedBy != "" { if conflict.ResolvedBy != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "| Resolved by: ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "| Resolved by: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 135, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 22, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div>")
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, 23, "</div><div id=\"conflict-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-4xl my-8\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">") return nil
})
}
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
func ConflictStatsBar(total int, unresolved int) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" 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(total)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 59, Col: 82}
}
_, 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, 15, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 63, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 67, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// ConflictsRefreshFragment is the /conflicts/list partial response: a
// fresh list plus the stats bar as an out-of-band swap. The client swaps
// this instead of re-rendering conflict cards from JSON (rendering
// ownership: templ renders, JS only swaps).
func ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var13 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div id=\"conflicts-list\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\" hx-swap-oob=\"true\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
if templ_7745c5c3_Var14 == nil {
templ_7745c5c3_Var14 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<body x-data=\"conflicts\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/conflicts").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" onchange=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"bulkResolve()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check", "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, 27, "Resolve Selected</button> <button @click=\"bulkDismiss()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div><div id=\"conflict-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-4xl my-8\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -261,7 +384,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
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, 24, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -269,7 +392,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
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, 25, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+9 -68
View File
@@ -53,62 +53,6 @@ function initializeCollectionWebSocket(): void {
}); });
} }
function renderCollectionBooks(books: BookInfo[]): void {
const container = document.getElementById("books-container");
if (!container) return;
const dataEl = document.getElementById("collection-data");
const isSystem = dataEl?.dataset.isSystem === "true";
// Smart collections are rule-managed: no hand-pick actions either way.
const canEditMembership =
!isSystem && dataEl?.dataset.isSmart !== "true";
if (books.length === 0) {
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
return;
}
container.innerHTML = books
.map(
(book) => `
<div class="card p-4 rounded-2xl collection-book-card" data-title="${book.title}" data-author="${book.author || ""}" data-media-id="${book.media_item_id}">
<div class="flex gap-4">
${canEditMembership ? `
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input type="checkbox" class="w-5 h-5"
:checked="selectedBooks.includes('${book.media_item_id}')"
@change="toggleSelection('${book.media_item_id}')"/>
</label>
</div>` : ""}
<div class="flex-1 min-w-0">
<a href="/media/${book.media_item_id}">
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">${book.title}</h3>
</a>
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">by ${book.author}</p>` : ""}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href="/media/${book.media_item_id}">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="Cover"
class="w-full aspect-[3/4] object-cover rounded shadow-md"
onerror="this.src='/static/placeholder-book.svg'"/>
</a>
</div>
</div>
${canEditMembership ? `
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button @click="requestRemoveBook('${book.media_item_id}')" class="btn btn-secondary w-full">
🗑️ Remove from Collection
</button>
</div>` : ""}
</div>
`,
)
.join("");
Alpine.initTree(container);
}
async function loadCollectionRules(collectionId: string): Promise<void> { async function loadCollectionRules(collectionId: string): Promise<void> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
@@ -480,19 +424,16 @@ function initCollectionsPage(): void {
if (dataEl) dataEl.dataset.libraryId = libraryId; if (dataEl) dataEl.dataset.libraryId = libraryId;
await switchWithTransition("books-container", async () => { await switchWithTransition("books-container", async () => {
const param = libraryId ? `?library_id=${libraryId}` : ""; // Rendering ownership: the grid is server-rendered markup. Swap
const response = await fetch( // in the templ partial instead of re-rendering from JSON.
`/api/collections/${collId}${param}`, const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
{ await window.htmx.ajax(
headers: { "GET",
Authorization: `Bearer ${localStorage.getItem("token")}`, `/collections/${collId}/books-grid${param}`,
"Content-Type": "application/json", { target: "#books-container", swap: "innerHTML" },
},
},
); );
if (!response.ok) throw new Error("Failed to load collection"); const container = document.getElementById("books-container");
const data = await response.json(); if (container) Alpine.initTree(container);
renderCollectionBooks(data.books || []);
}); });
}, },
}); });
+8 -54
View File
@@ -2,19 +2,16 @@ import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function refreshConflicts(): Promise<void> { async function refreshConflicts(): Promise<void> {
const token = localStorage.getItem("token"); // Rendering ownership: the list and stats are server-rendered markup.
if (!token) return; // Swap in the templ partial (stats arrive as an out-of-band swap)
// instead of re-rendering cards from JSON.
try { try {
const response = await fetch("/api/conflicts", { await window.htmx.ajax("GET", "/conflicts/list", {
headers: { Authorization: `Bearer ${token}` }, target: "#conflicts-list",
swap: "outerHTML",
}); });
const list = document.getElementById("conflicts-list");
if (response.ok) { if (list) Alpine.initTree(list);
const data: ConflictListResponse = await response.json();
renderConflicts(data.conflicts);
updateConflictStats(data);
}
} catch (error) { } catch (error) {
console.error("Failed to refresh conflicts:", error); console.error("Failed to refresh conflicts:", error);
} }
@@ -123,49 +120,6 @@ async function dismissAllResolved(): Promise<void> {
} }
} }
function renderConflicts(conflicts: ConflictDetailResponse[]): void {
const container = document.getElementById("conflicts-list");
if (!container) return;
if (conflicts.length === 0) {
container.innerHTML =
'<p class="text-center p-4" style="color: var(--text-secondary)">No conflicts found</p>';
return;
}
container.innerHTML = conflicts
.map(
(conflict) => `
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium" style="color: var(--text-primary)">${conflict.media_item_title}</h3>
<p class="text-sm" style="color: var(--text-secondary)">${conflict.conflict_type} - ${conflict.resolution_status}</p>
</div>
${
conflict.resolution_status === "unresolved"
? `
<div class="flex space-x-2">
<button onclick="window.showResolveModal('${conflict.id}')" class="btn-primary px-3 py-1 rounded text-sm">Resolve</button>
</div>
`
: ""
}
</div>
</div>
`,
)
.join("");
}
function updateConflictStats(data: ConflictListResponse): void {
const totalEl = document.getElementById("conflicts-total");
const unresolvedEl = document.getElementById("conflicts-unresolved");
if (totalEl) totalEl.textContent = String(data.total);
if (unresolvedEl) unresolvedEl.textContent = String(data.unresolved);
}
function showResolveModal(conflictId: string): void { function showResolveModal(conflictId: string): void {
const modal = document.getElementById("resolve-modal"); const modal = document.getElementById("resolve-modal");
const conflictIdInput = document.getElementById( const conflictIdInput = document.getElementById(
+20
View File
@@ -1,2 +1,22 @@
declare module "markdown-it-footnote"; declare module "markdown-it-footnote";
declare module "turndown-plugin-gfm"; declare module "turndown-plugin-gfm";
// htmx is loaded as a classic script on every page and used from TS for
// partial swaps (rendering-ownership rule: templ renders, JS swaps).
interface HtmxAjaxOptions {
target?: string;
swap?: string;
values?: Record<string, string>;
headers?: Record<string, string>;
}
interface Window {
htmx: {
ajax(
method: string,
url: string,
options?: HtmxAjaxOptions,
): Promise<boolean>;
process(element: Element): void;
};
}