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:
1 parent
1fdf306f2d
commit
f1844591b4
9 files changed
+1166
-977
No files matched your search
+67
-89
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
+478
-444
File diff suppressed because it is too large.
Load diff
+115
-90
@@ -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);">
|
||||||
|
|||||||
+293
-170
@@ -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,29 +34,301 @@ 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 = []any{"theme-" + user.Theme}
|
if len(conflicts) == 0 {
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
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
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"conflicts\" class=\"")
|
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, 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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, conflict := range conflicts {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 72}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
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: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 31, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, 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, 3, "\">")
|
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 {
|
||||||
|
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, 7, "</span><div><h3 class=\"text-lg font-semibold\" 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(conflict.MediaItemTitle)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
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_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
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_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</p></div></div></div><button @click=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 44, Col: 73}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
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_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if conflict.ResolvedBy != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "| Resolved by: ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
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_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -61,7 +336,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, 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);\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -69,46 +344,15 @@ 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, 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);\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
templ_7745c5c3_Err = ConflictStatsBar(total, unresolved).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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);\">")
|
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
|
|
||||||
}
|
|
||||||
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -116,7 +360,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, 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);\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -124,136 +368,15 @@ 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, 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>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(conflicts) == 0 {
|
templ_7745c5c3_Err = ConflictsListContent(user, conflicts).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
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);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
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 {
|
|
||||||
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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, conflict := range conflicts {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"conflict-item card p-6\" data-conflict-id=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var7 string
|
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 111, Col: 72}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
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=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var8 string
|
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 117, Col: 40}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
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);\">")
|
|
||||||
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, 16, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var9 string
|
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 125, Col: 105}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96}
|
|
||||||
}
|
|
||||||
_, 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, 18, "</p></div></div></div><button @click=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var11 string
|
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 130, Col: 73}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
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: ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var12 string
|
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 133, Col: 70}
|
|
||||||
}
|
|
||||||
_, 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, 20, " ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if conflict.ResolvedBy != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "| Resolved by: ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var13 string
|
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 135, Col: 45}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
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\">")
|
|
||||||
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
@@ -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
@@ -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(
|
||||||
|
|||||||
Vendored
+20
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user