26 Commits
Author SHA1 Message Date
john-okeefe 2976dad4e5 Merge branch 'new-ui' into main
Release / build-and-push (push) Successful in 2m45s
UI/UX redesign with modern look (Kavita/Komga/Audiobookshelf vibe):
- Dashboard read-action JS fix with conflict routing
- Reader back button with sessionStorage referrer
- Collection detail page interactions (selection, bulk remove, book picker)
- Custom confirm dialog replacing native confirm()
- System collections read-only
- Collection modal icon/color selection
- OPDS base_url fix + setup gate requires base_url
- Progress page fixes (percentage formatting, blank fields)
- Devices page reform (KOReader plugin instructions, gear icon)
- Embed time/tzdata for Alpine container compatibility

# Conflicts:
#	templates/reader_templ.go
#	web/static/style.css
2026-08-06 15:24:48 -04:00
john-okeefe acc9d08c60 fix: embed time/tzdata so time.LoadLocation works in Alpine container
Alpine doesn't ship the IANA timezone database, causing
time.LoadLocation('America/New_York') to fail with 'Invalid timezone'
for every non-UTC option in the profile settings dropdown.
2026-08-06 15:18:22 -04:00
john-okeefe e09c55cecc feat(devices): reform Add Device modal with KOReader plugin instructions
- Add gear icon (proper cog) and use it for device settings button
- Replace KOReader manual identifier input with step-by-step plugin
  setup instructions (clone repo, enter server URL, approve pending reg)
- Show server URL with copy button pre-filled from baseURL
- Kobo keeps manual registration flow (device name + identifier)
- Comment out Web Browser and Mobile App options (not implemented)
- Use Alpine x-model on device-type select to toggle between
  KOReader instructions and Kobo registration form
2026-08-06 14:48:48 -04:00
john-okeefe 6f1c13998c fix(ui): populate Last Updated and Synced From fields on progress page
GetAllProgressData (SSR handler) was missing LastUpdated, DeviceIcon,
DeviceName, DeviceType, and EpubCFI fields that the template expects.
All showed blank. Now matches the API handler's field population.
2026-08-06 14:06:47 -04:00
john-okeefe 0a0caa9533 fix(ui): round progress percentage to 2 decimals on progress page
The large percentage badge displayed the raw float value unformatted
(e.g. 1.523456789%). Now uses %.2f for a clean display (e.g. 1.52%).
2026-08-06 13:25:54 -04:00
john-okeefe 4716790564 fix: OPDS base_url placeholder bug + setup gate requires base_url
Three bugs fixed:

1. Schema seeded base_url with fake placeholder 'bookhoard.example.com'.
   Removed seed; startup now seeds from BASE_URL env var only if DB row
   is empty (admin changes persist across restarts). One-time UPDATE
   clears the placeholder in existing installs.

2. config.GetBaseURL() had a broken type assertion (local SystemConfigRow
   vs database.SystemConfig) that always failed, returning . Admin panel
   showed env var fallback instead of actual DB value. Fixed with a
   function-type getter that properly wraps the DB query.

3. OPDS handler read base_url only from DB with no fallback. When DB had
   the placeholder, all feed links pointed to an unreachable domain,
   breaking KOReader search/download. Added deriveBaseURL() helper that
   falls back to the request Host/scheme when DB value is empty.

Setup gate improvements:
- isSetupComplete now requires both admin user AND non-empty base_url
- Setup middleware no longer exempts all /api/ routes; only allows
  /api/auth/register, /api/auth/login, /api/system/config before setup
  is complete. All other API routes get 503.
- Cache invalidated when base_url is saved via admin settings

Dev workflow:
- New bruno/NewDevDBSetup/SetBaseUrl.yml for dev DB setup
- NewDB.sh runs SetBaseUrl between RegisterUser and CreateEbookLibrary
2026-08-06 13:02:35 -04:00
john-okeefe 8e2c1a4b3a fix(ui): make icon and color selection work in collection modal
Three root causes, all fixed:

1. Icon buttons were created with setAttribute('onclick', ...) in
   populateIconGrid, but selectIcon is module-scoped (not on window),
   so clicking threw ReferenceError. Switch to addEventListener with
   a closure. Icon search/focus used plain oninput/onfocus attributes
   with the same problem — convert to Alpine @input/@focus.

2. selectColor's highlight selector queried [onclick="selectColor(...)\]
2026-08-06 11:35:14 -04:00
john-okeefe 4a870a7f18 fix(ui): keep search filter visible on system collections 2026-08-06 11:22:26 -04:00
john-okeefe 40dabfd788 feat(ui): hide management controls for system collections
System collections (Not Started, Continue Reading, etc.) compute
their contents dynamically from reading_progress, so manual
add/remove has no effect. Hide the search bar, Remove Selected
button, Add Books button, per-card checkboxes, and Remove buttons
when the collection is system, making the page visually read-only.

- Add IsSystem bool to CollectionData, populated from the database
  is_system_collection flag.
- Add data-is-system to #collection-data so the JS renderer can
  also conditionally omit controls on library switch.
- Wrap toolbar controls, book picker modal, card checkboxes, and
  remove buttons in if !collection.IsSystem in the template.
2026-08-06 11:08:29 -04:00
john-okeefe ef3c05714a fix(ui): custom confirm dialog, picker visibility, remove wiring
Three fixes for the collection detail page:

1. Picker modal never showed because the outer overlay div had
   style="display:none" with no x-show binding — add x-show bound
   to $store.bookPicker.isOpen plus a backdrop and click-to-close.

2. Replace native confirm() with an in-page Alpine modal for UI
   continuity. Add confirm dialog state (showConfirm, confirmMessage,
   pendingAction) and methods (requestRemoveBook, requestBulkRemove,
   executeConfirmed, closeConfirm) to the collections component.
   The actual API calls (doRemoveBook/doBulkRemove) are triggered only
   when the user confirms.

3. Rename removeBook → requestRemoveBook and bulkRemove →
   requestBulkRemove in template + JS renderer so the dialog opens
   instead of navigating or failing silently.
2026-08-06 10:55:37 -04:00
john-okeefe 816ee0ec80 fix(ui): wire up collection detail page interactions
The /collections/:id page had several broken features because three
referenced functions (removeBook, toggleBookForRemoval,
filterCollectionBooks) were never defined, and every book card was
wrapped in <a href="/media/..."> so clicking the checkbox or remove
button navigated to the book detail page instead.

Card restructure:
- Remove the <a> wrapper; title and cover are now individual links.
- Checkbox sits in a <label> with expanded click area (p-2 -m-2).
- Checkbox uses Alpine :checked/@change bound to a reactive
  selectedBooks array on the collections component.

Remove (single + bulk):
- Add removeBook(id) and bulkRemove() methods with confirm() dialogs.
- Wire the "Remove Selected" button with :disabled binding and @click.
- Selected-count badge is now Alpine-reactive (x-show/x-text).

Search within collection:
- Add filterCollectionBooks() that filters cards client-side by
  title/author via data-* attributes and @input.

Book picker ("Add Books"):
- Point the HTMX search inputs at the existing /api/media-items/search
  endpoint instead of the non-existent /api/media-items/filtered.
- Add hx-trigger="loadBooks" + hx-get to the grid so loadBooks()
  actually fires an initial request when the picker opens.
- Merge the hidden limit/offset inputs into the #book-picker-filters
  div so hx-include picks them up (was a separate <form id=filter-form>
  that nobody referenced).
- Add show_checkbox mode to handleSearchHTML: when present, render a
  new BookPickerGrid template with clickable, selectable cards instead
  of the reader BookCard.
- Fix bookPicker submit() to location.reload() instead of a non-existent
  reloadCollection HTMX event, and clearFilters() to target text inputs.
2026-08-06 10:40:18 -04:00
john-okeefe 73a2852ee3 fix(reader): back button remembers the page you came from
The reader's back button was hardcoded to the book detail page
(/media/{id}), so even when you launched the reader straight from the
dashboard the back button ignored that and sent you to the detail view.

Mirror the existing book-detail.ts referrer pattern: capture
document.referrer into sessionStorage on load (excluding other reader
pages and the reader's own URL), then override the back link's click to
navigate there. Falls back to the link's original href (/media/{id}) when
no valid referrer exists (direct URL access).
2026-08-06 09:51:04 -04:00
john-okeefe f67232a20b fix(ui): show read action on JS-rendered dashboard cards
The dashboard re-renders its sections client-side (library switch,
refresh, saving settings) via renderBookCard in dashboard.ts, which was
still the old markup with no .book-card-action overlay. So the read
button appeared on the server-rendered cards but vanished as soon as the
dashboard re-rendered, while the bookshelf (always templ-rendered) kept
working.

- Rewrite renderBookCard to match the templ BookCard: detail link plus
  the play/read action overlay, routing to the reader or the detail page
  when the book has an active conflict.
- Add has_conflict to the BookInfo TS type and stamp it in the dashboard
  sections API (GetSections) so client-rendered cards can route correctly.
- Add pointer-events-none / group-hover:pointer-events-auto to the
  client-rendered carousel nav buttons so they no longer swallow hover
  over edge cards, matching the templ fix.
2026-08-06 08:40:06 -04:00
john-okeefe 7b465ac97e fix(ui): use a grid icon for Customize Dashboard
The settings icon is a circle with radiating spokes, which reads as a
sun/light-mode toggle rather than a dashboard control. Swap it for the
grid icon, the standard dashboard-layout affordance.
2026-08-06 08:24:54 -04:00
john-okeefe 033a012069 feat(ui): group sidebar footer menus into distinct panels
The account and appearance menus sat in one container with no separation,
so expanding one made the other hard to find. Each collapsible menu now
lives in its own subtly lifted, bordered panel (.sidebar-panel) so the
expanded items stay visually contained and the menus never blend together.
Applied to the account, appearance, and sign-in menus.
2026-08-06 08:14:10 -04:00
john-okeefe 9165c4eb3f feat(ui): use a book-open icon for the card read action
Swaps the play (triangle) icon for book-open on book cards, since the
action is to start reading rather than play media.
2026-08-06 08:01:33 -04:00
john-okeefe 9ef6c5b6ed feat(ui): split book-card play action into reader/detail routing
The play button on book cards now opens the reader directly, instead of
always going to the detail page. Cards with an active progress sync
conflict route the play button to the detail page (which hosts the
conflict dialogue and resolves before writing progress), so the user is
never silently dropped into the reader with an unresolved conflict.

Backend:
- Add HasConflict to BookInfo and stamp it via ListSyncConflictsByUser
  (MarkActiveConflicts / MarkActiveConflictsSections) on the dashboard,
  bookshelf, series, tag, and search result card builders.
- Each page issues a single conflict query regardless of card count.

BookCard:
- Restructure into a detail link (cover + meta) with the play action as a
  sibling overlay using a pointer-events split: the container passes
  clicks through to detail while only the circular button routes to the
  reader. No nested anchors.
- On touch devices (hover: none) the play button stays visible.

Fix: carousel nav buttons had opacity-0 without pointer-events-none, so
they swallowed hover/clicks over book cards on the dashboard. They are
now click-through until the carousel is hovered.
2026-08-06 07:52:20 -04:00
john-okeefe 987ece38f0 fix(reader): keep footer compact on mobile and clear text under chrome
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.

Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.

Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.

Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
2026-08-05 21:58:42 -04:00
john-okeefe c5c2270007 fix(reader): keep footer compact on mobile and clear text under chrome
Release / build-and-push (push) Successful in 2m39s
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.

Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.

Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.

Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
2026-08-05 21:48:28 -04:00
john-okeefe b23f6b0bab build: regenerate stylesheet for sidebar app shell 2026-08-05 16:45:28 -04:00
john-okeefe 8ea70ea7f8 feat(ui): migrate remaining pages to the new shell
Apply the sidebar shell and bold primitives across the rest of the app so
the whole experience shares one visual language:

- Browse/organize: series (keeps stacked-covers/series-card CSS),
  collections (keeps wood-paneling + carousel classes), browse_detail.
- Account/stats: profile + form + modal, progress, analytics (stat-card
  tiles), devices, conflicts (status semantics), queue (status/priority
  badges).
- Admin: admin sidebar restyled with icons, dashboard/users/library/
  processing-issues/settings migrated to .card/.btn/.input primitives.
- Docs/setup/misc: docs (keeps prose/highlight.js), api_explorer, setup
  wizard, unlinked_books, custom_section builder.
- Modals/fragments: collection_modal, collection_rules,
  restore_system_collection_modal, filter_item, book_detail_modals — all
  overlays use --surface-overlay + --shadow-pop.

Every Alpine handler, HTMX attribute, id, name, and data-* is preserved;
only presentation changes.
2026-08-05 16:45:02 -04:00
john-okeefe 07578e0206 feat(ui): redesign dashboard, bookshelf, book detail, and auth pages
- Dashboard: page heading, bold section headers with accent icon tiles,
  carousel chevrons as SVG icons with theme-aware gradients (wood-paneling
  gradient classes preserved), and a cinematic BookCard (hover overlay with
  a quick-action button). BookCard is now fluid so it fills both the
  carousel slot and the bookshelf grid.
- Bookshelf: the filter wall becomes a search toolbar + a slide-in filter
  drawer (filtersOpen state added to the bookshelf Alpine component). Every
  filter input, the tristate cover toggle, tag autocomplete, save/load/clear
  actions, HTMX search/sort, and pagination are preserved.
- Book detail: blurred cover backdrop hero, rounded-2xl cover, bold
  typography, .btn action bar, progress/metadata cards, chip-style external
  links. All interactive rating, modals, and data-attrs preserved.
- Auth/landing: brand-gradient hero for index/login/register, icon feature
  cards, data-driven theme select (ThemeOptions). All ids (#theme-select,
  #result, #auth-result, password-requirement ids) and Alpine init preserved.
2026-08-05 16:44:52 -04:00
john-okeefe f402a3ee03 feat(ui): sidebar app shell and bold design system
Replace the top-nav with a fixed left sidebar + slim content topbar
(the Komga/Audiobookshelf layout), the signature change versus the
conservative tighten-ui branch.

- App shell: .app-sidebar (off-canvas on mobile via Alpine mobileMenuOpen,
  pinned at 16rem on lg), .app-topbar (fixed, blurred, 4rem), and
  .app-subbar for in-page sticky bars (parks under the topbar). Content is
  auto-offset via body:has(.app-sidebar) so reader.templ/error.templ (which
  have no sidebar) are untouched.
- Header rebuilt as the sidebar: logo + vertical nav (activeClass), an
  inline Appearance picker driven by ThemeOptions/WoodOptions, and an
  inline account menu / sign-in (preserving the inline htmx login). The
  search lives in the topbar so its dropdown still anchors correctly.
- Bolder primitives: .card -> rounded-2xl, cinematic .book-card-cover
  hover overlay with a quick-action affordance, .brand-gradient hero
  surface, .hero-backdrop (blurred cover) and .stat-card utilities.
2026-08-05 16:44:42 -04:00
john-okeefe f3f908eb7e feat(ui): design system foundation
Add semantic design tokens and base primitives to replace the verbose
inline var() styling that made the UI feel dated.

- Rename colliding Tailwind color tokens (bg-primary/bg-secondary) to
  semantic names (surface/surface-raised/content/content-muted/brand/line)
- Derive hover, overlay, border-strong, accent-muted and elevation tokens
  once on <body> so they adapt to every theme automatically
- Make :root mirror Tokyo Night to kill the first-paint theme flash
- Add component primitives in @layer components: .card (surface + soft
  shadow, no hard border), .btn variants, .input, .chip, .badge, .icon-btn
- Add theme-aware status/priority badges and global focus-visible styling
- Add an inline-SVG Icon() component (consistent stroke language) to
  replace the mixed emoji/SVG iconography
- Data-drive theme/wood options and add an active-nav helper
- Add skeleton shimmer + x-cloak support
2026-08-05 16:03:07 -04:00
john-okeefe 94a4facc6c chore(deps): promote golang.org/x/net to direct dependency
Release / build-and-push (push) Successful in 3m2s
Pulled in as a direct requirement by templ v0.3.1020 code generation; it was previously only an indirect dependency.
2026-08-05 15:31:48 -04:00
john-okeefe 11617c1860 fix(reader): tighten mobile header/footer and add safe-area margins
The reader chrome used the same dimensions at every screen size, and the book viewport offset was hardcoded to 52px. This made the header/footer oversized on phones and left the body text flush against (or overlapping) the bars, with no handling for notched-device safe areas.

- Add viewport-fit=cover so notched devices expose safe-area insets.
- Shrink the top bar on mobile (px-3 py-2 / text-base, scaling up at sm:) and hide the chapter title on phones (hidden sm:block sm:truncate).
- Shrink the bottom bar on mobile (tighter padding/gap, p-1.5 sm:p-2 on buttons) while keeping all controls visible.
- Replace the hardcoded top-[52px] bottom-[52px] viewport offsets with responsive calc() values (44/48px mobile, 60px at sm:) that fold in env(safe-area-inset-*), plus matching safe-area padding on the bars, so the book content always clears the chrome with a visible margin.

Regenerates reader_templ.go and rebuilds style.css.
2026-08-05 15:31:40 -04:00
103 changed files with 9342 additions and 5443 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
+38
View File
@@ -0,0 +1,38 @@
info:
name: SetBaseUrl
type: http
seq: 3
http:
method: PUT
url: '{{base_url}}/api/system/config'
auth: inherit
body:
type: json
jsonBody: |-
{
"base_url": "http://localhost:8765"
}
headers:
- key: Authorization
value: Bearer {{token}}
- key: Content-Type
value: application/json
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Set Base URL
Configures the server's base_url during initial dev database setup.
Must be run after RegisterUser (which provides the auth token) and before
any library/device creation (which require setup to be complete).
**Method:** PUT
**Endpoint:** /api/system/config
**Auth:** Bearer token (from RegisterUser)
+31
View File
@@ -14,6 +14,8 @@ import (
"log"
"time"
_ "time/tzdata"
"github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
@@ -48,6 +50,35 @@ func main() {
}
log.Println("✅ Database schema initialized and verified, starting server...")
// Seed base_url from env var if not already configured. Uses conditional
// UPDATE so admin-set values are never overwritten on restart.
if cfg.BaseURL != "" {
_, err = dbPool.Exec(ctx, `
INSERT INTO system_config (key, value)
VALUES ('base_url', $1)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value
WHERE system_config.value = ''
`, cfg.BaseURL)
if err != nil {
log.Printf("⚠️ Could not seed base_url: %v", err)
} else {
// Also seed derived URLs
for key, suffix := range map[string]string{
"opds_base_url": "/opds",
"api_base_url": "/api",
} {
_, _ = dbPool.Exec(ctx, `
INSERT INTO system_config (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value
WHERE system_config.value = ''
`, key, cfg.BaseURL+suffix)
}
}
}
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
+8 -6
View File
@@ -1149,12 +1149,14 @@ CREATE TABLE IF NOT EXISTS system_config (
updated_by UUID REFERENCES users(id)
);
-- Pre-seeded values
INSERT INTO system_config (key, value) VALUES
('base_url', 'https://bookhoard.example.com'),
('opds_base_url', 'https://bookhoard.example.com/opds'),
('api_base_url', 'https://bookhoard.example.com/api')
ON CONFLICT (key) DO NOTHING;
-- One-time cleanup: clear the old placeholder seed so the startup logic
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
UPDATE system_config SET value = ''
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
UPDATE system_config SET value = ''
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
UPDATE system_config SET value = ''
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
-- Create opds_tokens table (device-specific OPDS access tokens)
CREATE TABLE IF NOT EXISTS opds_tokens (
+1 -1
View File
@@ -29,6 +29,7 @@ require (
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/text v0.36.0
)
@@ -57,7 +58,6 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/xyproto/randomstring v1.2.0 // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
+8 -19
View File
@@ -45,30 +45,19 @@ func (c *Config) DatabaseURL() string {
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
}
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
func GetBaseURL(ctx context.Context, db interface{}) string {
// Try to get from database first
type SystemConfigQuerier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
}
// SystemConfigGetter returns the value for a system config key, or an error.
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
if querier, ok := db.(SystemConfigQuerier); ok {
config, err := querier.GetSystemConfig(ctx, "base_url")
if err == nil && config.Value != "" {
return config.Value
}
// GetBaseURL returns the base URL from system configuration database, or empty
// string if not set. The getter abstraction avoids importing the database package.
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
val, err := getter(ctx, "base_url")
if err == nil && val != "" {
return val
}
// Fallback: return empty string - caller should use their own fallback
return ""
}
// SystemConfigRow represents a system configuration row
type SystemConfigRow struct {
Key string
Value string
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
+1
View File
@@ -73,6 +73,7 @@ type BookInfo struct {
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
HasConflict bool `json:"has_conflict"`
}
type SectionData struct {
+55
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"context"
"log"
"net/http"
"strconv"
@@ -64,6 +65,7 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
}
sectionData := BuildSections(sections, libraryID)
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
}
@@ -188,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
return result
}
// activeConflictSet returns the set of media item IDs (as strings) that have an
// active (unresolved) progress sync conflict for the given user. A single query
// is issued; resolved conflicts are filtered out in memory.
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
if err != nil {
return nil
}
set := make(map[string]bool, len(conflicts))
for _, c := range conflicts {
if c.ResolutionStatus.String == "unresolved" {
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
}
}
return set
}
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
// active progress sync conflict for the user. It performs a single query
// regardless of how many books are passed.
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
if len(books) == 0 {
return books
}
set := activeConflictSet(ctx, db, userID)
for i := range books {
if set[books[i].MediaItemID] {
books[i].HasConflict = true
}
}
return books
}
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
// used by the dashboard which renders books grouped into sections.
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
if len(sections) == 0 {
return sections
}
set := activeConflictSet(ctx, db, userID)
if len(set) == 0 {
return sections
}
for s := range sections {
for i := range sections[s].Items {
if set[sections[s].Items[i].MediaItemID] {
sections[s].Items[i].HasConflict = true
}
}
}
return sections
}
func getViewAllURL(collectionID string, libraryID string) string {
if collectionID != "" {
if libraryID != "" {
+7 -7
View File
@@ -38,15 +38,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
}
}
// Helper function to get base URL from system config
// Helper function to get base URL from system config with request-derived fallback
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
if err != nil {
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
var dbBaseURL string
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
dbBaseURL = config.Value
}
opdsBaseURL := baseURL.Value + "/opds"
return baseURL.Value, opdsBaseURL, nil
baseURL := deriveBaseURL(c, dbBaseURL)
opdsBaseURL := baseURL + "/opds"
return baseURL, opdsBaseURL, nil
}
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
+10
View File
@@ -374,6 +374,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
deviceName = progress.LastSyncDevice.String
}
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
}
progressList = append(progressList, ProgressWithMedia{
MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title,
@@ -386,6 +391,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
ProgressPercentage: progress.Percentage.Float64 * 100,
EpubCFI: epubcfi,
LastUpdated: lastUpdated,
DeviceIcon: getDeviceIcon(deviceName),
DeviceName: deviceName,
DeviceType: deviceName,
FormatGroup: mediaItem.FormatGroup,
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
})
+19 -18
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/setupstatus"
"encoding/json"
"fmt"
"net/http"
@@ -81,15 +82,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get base URL and compute paths
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Get base URL and compute paths (with request-derived fallback)
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
baseURL := deriveBaseURL(c, dbBaseURL.Value)
// Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
// Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -176,8 +175,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL,
OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: baseURL.Value + "/api",
OPDSBaseURL: baseURL + "/opds",
APIBaseURL: baseURL + "/api",
DeviceID: deviceID.String(),
DeviceToken: device.AuthToken,
},
@@ -219,15 +218,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get base URL and compute paths
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Get base URL and compute paths (with request-derived fallback)
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
baseURL := deriveBaseURL(c, dbBaseURL.Value)
// Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
// Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -309,8 +306,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL,
OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: baseURL.Value + "/api",
OPDSBaseURL: baseURL + "/opds",
APIBaseURL: baseURL + "/api",
DeviceID: deviceID.String(),
DeviceToken: device.AuthToken,
},
@@ -434,6 +431,10 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
})
}
}
// Invalidate setup status cache so the middleware picks up the new
// base_url immediately (setup is not complete until base_url is set).
setupstatus.Invalidate()
}
// Check for HTMX request
+36
View File
@@ -0,0 +1,36 @@
package handlers
import (
"strings"
"github.com/labstack/echo/v5"
)
// deriveBaseURL returns the base URL to use for constructing self-referential
// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured
// base_url when available, and falls back to deriving the URL from the incoming
// HTTP request (Host header + scheme), which is always reachable by the client.
//
// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so
// that deployments behind TLS-terminating reverse proxies advertise the correct
// external URL.
func deriveBaseURL(c *echo.Context, dbBaseURL string) string {
if dbBaseURL != "" {
return strings.TrimRight(dbBaseURL, "/")
}
scheme := "http"
if c.Request().TLS != nil {
scheme = "https"
}
if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
host := c.Request().Host
if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" {
host = forwarded
}
return scheme + "://" + host
}
+13 -14
View File
@@ -9,7 +9,6 @@ import (
"strconv"
"time"
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
@@ -215,6 +214,9 @@ func registerFrontendRoutes(cfg *Config) {
bookInfoList = []handlers.BookInfo{}
}
seriesUserUUID, _ := uuid.Parse(user.ID)
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList)
var buf bytes.Buffer
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
@@ -267,6 +269,9 @@ func registerFrontendRoutes(cfg *Config) {
bookInfoList = []handlers.BookInfo{}
}
tagUserUUID, _ := uuid.Parse(user.ID)
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: tagUserUUID, Valid: true}, bookInfoList)
var buf bytes.Buffer
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
@@ -354,6 +359,7 @@ func registerFrontendRoutes(cfg *Config) {
}
var buf bytes.Buffer
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
if err != nil {
return err
@@ -408,7 +414,8 @@ func registerFrontendRoutes(cfg *Config) {
// Get only visible sections for the dashboard display
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
sectionData := handlers.BuildSections(visibleSections, libraryID)
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
allSectionsData := handlers.BuildSections(allSections, libraryID)
var buf bytes.Buffer
@@ -623,6 +630,7 @@ func registerFrontendRoutes(cfg *Config) {
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
}
var buf bytes.Buffer
@@ -725,10 +733,7 @@ func registerFrontendRoutes(cfg *Config) {
}
// Get base URL from database config with fallback to config/env var
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
baseURL := cfg.getBaseURL(c.Request().Context())
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
@@ -986,10 +991,7 @@ func registerFrontendRoutes(cfg *Config) {
}
// Fetch current system configuration - just base_url
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
baseURL := cfg.getBaseURL(c.Request().Context())
systemConfig := map[string]string{
"base_url": baseURL,
@@ -1077,10 +1079,7 @@ func registerFrontendRoutes(cfg *Config) {
}
// Get base URL from database config with fallback to config/env var
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
baseURL := cfg.getBaseURL(c.Request().Context())
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
+18
View File
@@ -72,6 +72,24 @@ type Config struct {
LibraryService *services.LibraryService
}
// getBaseURL returns the configured base URL from the database, falling back to
// the env var / config default. Uses a closure to adapt the database query to
// config.SystemConfigGetter.
func (cfg *Config) getBaseURL(ctx context.Context) string {
getter := func(ctx context.Context, key string) (string, error) {
row, err := cfg.Queries.GetSystemConfig(ctx, key)
if err != nil {
return "", err
}
return row.Value, nil
}
baseURL := config.GetBaseURL(ctx, getter)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
return baseURL
}
// createJWTMiddleware creates a JWT middleware with proper user context setup
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{
+8 -2
View File
@@ -112,9 +112,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
}
}
// Render using BooksGrid template
// Stamp active conflict flags so cards route the play action correctly
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
var buf bytes.Buffer
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
if c.QueryParam("show_checkbox") == "true" {
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
} else {
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
}
if err != nil {
log.Printf("Template render error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
+40 -10
View File
@@ -14,7 +14,40 @@ import (
)
func isSetupComplete(cfg *Config) bool {
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
getter := func(ctx context.Context) (string, error) {
row, err := cfg.Queries.GetSystemConfig(ctx, "base_url")
if err != nil {
return "", err
}
return row.Value, nil
}
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries, getter)
}
// setupAllowedAPIRoutes lists API endpoints that remain accessible before
// initial setup is complete so the server can be configured via API.
var setupAllowedAPIRoutes = []string{
"/api/auth/register",
"/api/auth/login",
"/api/system/config",
}
// isAllowedDuringSetup reports whether a request path should bypass the setup
// gate. This includes the setup page itself, static assets, health checks, and
// the minimal set of API routes needed to perform initial configuration.
func isAllowedDuringSetup(path string) bool {
if path == "/setup" || path == "/setup/" {
return true
}
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
return true
}
for _, route := range setupAllowedAPIRoutes {
if path == route || strings.HasPrefix(path, route+"/") {
return true
}
}
return false
}
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
@@ -22,19 +55,16 @@ func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(c *echo.Context) error {
path := c.Request().URL.Path
if path == "/setup" || path == "/setup/" {
return next(c)
}
if strings.HasPrefix(path, "/api/") {
return next(c)
}
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
if isAllowedDuringSetup(path) {
return next(c)
}
if !isSetupComplete(cfg) {
if strings.HasPrefix(path, "/api/") {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
"error": "Server setup is not complete. Configure an admin account and base_url via the setup wizard or API.",
})
}
return c.Redirect(http.StatusFound, "/setup")
}
+27 -10
View File
@@ -1,8 +1,9 @@
// Package setupstatus reports whether the application's initial setup has been
// completed. Setup is considered complete as soon as at least one admin user
// exists, regardless of how that user was created (setup wizard, API, or a
// future CLI). This keeps the setup gate a derived property of real data
// rather than a manually-flipped flag that can drift out of sync.
// completed. Setup is considered complete when at least one admin user exists
// AND a non-empty base_url has been configured, regardless of how those were
// created (setup wizard, API, or a future CLI). This keeps the setup gate a
// derived property of real data rather than a manually-flipped flag that can
// drift out of sync.
package setupstatus
import (
@@ -18,6 +19,12 @@ type AdminCounter interface {
CountAdmins(ctx context.Context) (int64, error)
}
// BaseURLGetter returns the configured base_url value from the database, or an
// error if it cannot be read. Defined as a function type (not an interface) so
// it can be satisfied by a closure wrapping *database.Queries.GetSystemConfig
// without importing the database package.
type BaseURLGetter func(ctx context.Context) (string, error)
var (
cacheMu sync.RWMutex
cacheComplete bool = true
@@ -26,10 +33,11 @@ var (
)
// IsSetupComplete reports whether setup is complete. Setup is complete when at
// least one admin user exists. A short in-memory cache avoids hammering the
// database on every request. On a database error the function fails open
// (returns true) so a transient outage does not lock users out of the app.
func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
// least one admin user exists AND base_url is configured. A short in-memory
// cache avoids hammering the database on every request. On a database error the
// function fails open (returns true) so a transient outage does not lock users
// out of the app.
func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLGetter) bool {
cacheMu.RLock()
if time.Now().Before(cacheExpiry) {
complete := cacheComplete
@@ -38,12 +46,20 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
}
cacheMu.RUnlock()
count, err := q.CountAdmins(ctx)
complete := true
count, err := q.CountAdmins(ctx)
if err == nil {
complete = count > 0
}
if complete && baseURLGetter != nil {
baseURL, err := baseURLGetter(ctx)
if err == nil {
complete = baseURL != ""
}
}
cacheMu.Lock()
cacheComplete = complete
cacheExpiry = time.Now().Add(cacheTTL)
@@ -53,7 +69,8 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
// Invalidate clears the cached setup status so the next call to IsSetupComplete
// re-reads from the database. Call this after any write that could change the
// admin user count (user creation, role promotion/demotion, user deletion).
// admin user count (user creation, role promotion/demotion, user deletion) or
// the base_url configuration.
func Invalidate() {
cacheMu.Lock()
cacheComplete = true
+36 -18
View File
@@ -44,25 +44,43 @@ const config: Config = {
],
},
colors: {
primary: {
DEFAULT: "#7aa2f7",
50: "#f0f9ff",
100: "#e0f2fe",
200: "#bae6fd",
300: "#7dd3fc",
400: "#38bdf8",
500: "#0ea5e9",
600: "#0284c7",
700: "#0369a1",
800: "#075985",
900: "#0c4a6e",
// Semantic surface tokens (page bg, cards, raised layers)
surface: {
DEFAULT: "var(--bg-primary)",
raised: "var(--bg-secondary)",
hover: "var(--surface-hover)",
overlay: "var(--surface-overlay)",
},
"bg-primary": "var(--bg-primary)",
"bg-secondary": "var(--bg-secondary)",
"text-primary": "var(--text-primary)",
"text-secondary": "var(--text-secondary)",
accent: "var(--accent)",
border: "var(--border)",
// Text tokens
content: {
DEFAULT: "var(--text-primary)",
muted: "var(--text-secondary)",
},
// Accent / brand
brand: {
DEFAULT: "var(--accent)",
muted: "var(--accent-muted)",
},
// Borders / hairlines
line: {
DEFAULT: "var(--border)",
strong: "var(--border-strong)",
},
// Status colors (theme-aware via vars, fall back to fixed)
success: "var(--status-success)",
warning: "var(--status-warning)",
danger: "var(--status-danger)",
info: "var(--status-info)",
},
borderRadius: {
xl: "0.875rem",
"2xl": "1.25rem",
},
boxShadow: {
card: "var(--shadow-card)",
"card-hover": "var(--shadow-card-hover)",
pop: "var(--shadow-pop)",
bar: "var(--shadow-bar)",
},
backgroundImage: {
"wood-light": "url('/static/textures/wood-light.png')",
+54 -32
View File
@@ -11,60 +11,79 @@ templ Admin(user User) {
</head>
<body x-data="admin" x-init="loadWatchStatus(); initializeScanWebSocket()" class="theme-tokyo-night">
@Header(user, "/admin")
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
<div class="flex">
@AdminSidebar(user, "/admin")
<main class="flex-1 p-8">
<div class="max-w-4xl">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1>
<p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("grid", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Dashboard</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex items-center space-x-3">
<div class="text-3xl">📖</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="stat-card">
<div class="flex items-center gap-3">
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("library", "h-5 w-5")
</span>
<div>
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
<p style="color: var(--text-secondary)" class="text-sm">Manage your ebook collection</p>
<p class="text-sm" style="color: var(--text-secondary)">Manage your ebook collection</p>
</div>
</div>
<a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a>
<a href="/" class="btn btn-secondary mt-4 text-sm">
@Icon("arrow-right", "h-4 w-4")
View Library
</a>
</div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex items-center space-x-3">
<div class="text-3xl">👁️</div>
<div class="stat-card">
<div class="flex items-center gap-3">
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("sync", "h-5 w-5")
</span>
<div>
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3>
<p style="color: var(--text-secondary)" class="text-sm">Auto-detecting new files</p>
<p class="text-sm" style="color: var(--text-secondary)">Auto-detecting new files</p>
</div>
</div>
<div id="watch-status" class="mt-4 text-sm" style="color: var(--text-secondary)">
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
<span class="inline-block w-2 h-2 rounded-full mr-2" style="background-color: var(--status-success);"></span>
Watching <span id="watch-count">0</span> libraries
</div>
</div>
</div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
<div class="card p-6">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button @click="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left">
<div class="font-medium">Rescan Library</div>
<div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div>
<button @click="scanAllLibraries()" class="btn btn-primary py-4 flex-col items-start gap-1">
<span class="flex items-center gap-2 font-medium">
@Icon("refresh", "h-5 w-5")
Rescan Library
</span>
<span class="text-xs font-normal opacity-80">Re-scan existing files and fix metadata</span>
</button>
<a href="/admin/library" class="btn-secondary p-4 rounded-lg text-left block">
<div class="font-medium">Manage Libraries and Folders</div>
<div style="color: var(--text-secondary)" class="text-sm">Add or remove libraries and scan directories</div>
<a href="/admin/library" class="btn btn-secondary py-4 flex-col items-start gap-1">
<span class="flex items-center gap-2 font-medium">
@Icon("library", "h-5 w-5")
Manage Libraries
</span>
<span class="text-xs font-normal opacity-80">Add or remove libraries and scan directories</span>
</a>
</div>
</div>
<!-- Scan Progress Section -->
<div id="scan-progress-container" class="hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div id="scan-progress-container" class="card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">
📚 Scanning Libraries
<h3 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
@Icon("refresh", "h-5 w-5")
Scanning Libraries
</h3>
<button @click="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded">
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<!-- Overall Progress -->
@@ -73,7 +92,7 @@ templ Admin(user User) {
<span style="color: var(--text-secondary)">Overall Progress</span>
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div class="w-full rounded-full h-3" style="background-color: var(--surface-hover);">
<div
id="scan-progress-bar"
class="h-3 rounded-full transition-all duration-500"
@@ -89,21 +108,24 @@ templ Admin(user User) {
<!-- Dynamically populated -->
</div>
<!-- Results Summary -->
<div id="scan-results" class="hidden mt-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
<h4 class="font-semibold mb-2" style="color: var(--text-primary)"> Scan Complete!</h4>
<div id="scan-results" class="hidden mt-6 p-4 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border);">
<h4 class="font-semibold mb-2 flex items-center gap-2" style="color: var(--status-success);">
@Icon("check-circle", "h-5 w-5")
Scan Complete!
</h4>
<div id="scan-results-content" style="color: var(--text-secondary)">
<!-- Results populated by JS -->
</div>
<div class="mt-4 flex gap-2">
<button
@click="window.location.reload()"
class="btn-primary px-4 py-2 rounded-lg"
class="btn btn-primary"
>
Refresh to View Books
</button>
<button
@click="hideScanProgress()"
class="btn-secondary px-4 py-2 rounded-lg"
class="btn btn-secondary"
>
Dismiss
</button>
+91 -53
View File
@@ -10,49 +10,69 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
</head>
<body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
@Header(user, "/admin/library")
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
<div class="flex">
@AdminSidebar(user, "/admin/library")
<main class="flex-1 p-8">
<div class="w-full">
<div class="max-w-5xl">
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
Back to Dashboard
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
<a href="/admin" class="btn btn-secondary">
@Icon("arrow-left", "h-4 w-4")
Back to Dashboard
</a>
<button data-action="show-create-modal" class="btn-primary px-4 py-2 rounded-lg font-medium">
+ Create Library
<button data-action="show-create-modal" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
Create Library
</button>
</div>
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Library Management</h1>
<p style="color: var(--text-secondary)">Manage libraries and configure media scanning</p>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("library", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Library Management</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Manage libraries and configure media scanning</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Libraries Section -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Libraries</h3>
<p style="color: var(--text-secondary)" class="mb-4">Manage media libraries and their folders</p>
<div id="libraries-list" class="space-y-3 mb-6">
<div class="card p-6">
<div class="flex items-center gap-2 mb-1">
@Icon("library", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Libraries</h3>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary)">Manage media libraries and their folders</p>
<div id="libraries-list" class="space-y-3 mb-2">
if len(libraries) == 0 {
<p style="color: var(--text-secondary)" class="text-center py-8">
<p class="text-center py-8 text-sm" style="color: var(--text-secondary)">
No libraries yet. Create your first library to get started.
</p>
} else {
for _, library := range libraries {
<div class="p-4 border rounded-lg" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="flex justify-between items-start mb-2">
<div>
<div class="p-4 rounded-xl border transition-colors hover:bg-surface-hover" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="flex justify-between items-start gap-3 mb-2">
<div class="min-w-0">
<h4 class="font-semibold" style="color: var(--text-primary)">{ library.Name }</h4>
if library.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ library.Description }</p>
}
<span class="inline-block px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">
<span class="chip mt-1">
@Icon("tag", "h-3 w-3")
{ library.TypeName }
</span>
</div>
<div class="flex space-x-2">
<button data-library-id={ library.ID } data-action="show-folders" class="text-xs px-2 py-1 rounded" style="background-color: var(--bg-secondary); color: var(--text-primary)">Folders</button>
<button data-library-id={ library.ID } data-action="edit" class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary)">Edit</button>
<button data-library-id={ library.ID } data-action="delete" class="text-xs px-2 py-1 rounded text-red-500">Delete</button>
<div class="flex flex-wrap gap-1 shrink-0">
<button data-library-id={ library.ID } data-action="show-folders" class="btn btn-secondary text-xs px-2.5 py-1">
@Icon("folder", "h-4 w-4")
Folders
</button>
<button data-library-id={ library.ID } data-action="edit" class="btn btn-primary text-xs px-2.5 py-1">
@Icon("edit", "h-4 w-4")
Edit
</button>
<button data-library-id={ library.ID } data-action="delete" class="btn btn-danger text-xs px-2.5 py-1">
@Icon("trash", "h-4 w-4")
Delete
</button>
</div>
</div>
<div id={ "library-folders-" + library.ID } class="hidden mt-3 space-y-2"></div>
@@ -62,20 +82,26 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
</div>
</div>
<!-- User Library Visibility Section -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Library Visibility</h3>
<p style="color: var(--text-secondary)" class="mb-4">Control which libraries are visible to users</p>
<div class="card p-6">
<div class="flex items-center gap-2 mb-1">
@Icon("check-circle", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Library Visibility</h3>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary)">Control which libraries are visible to users</p>
<div id="visibility-controls" class="space-y-4">
<!-- Visibility controls will be loaded here -->
</div>
</div>
</div>
<!-- User Visibility Management -->
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">User Library Access</h3>
<p style="color: var(--text-secondary)" class="mb-4">Manage individual user access to specific libraries</p>
<div class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-1">
@Icon("users", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">User Library Access</h3>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary)">Manage individual user access to specific libraries</p>
<div class="mb-4">
<select id="user-select" onchange="loadUserVisibility()" class="px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
<select id="user-select" onchange="loadUserVisibility()" class="input w-auto">
<option value="">Select a user...</option>
for _, user := range users {
<option value="{ user.ID }">{ user.Username } ({ user.Email })</option>
@@ -90,43 +116,50 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
</main>
</div>
<!-- Create Library Modal -->
<div id="create-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div id="create-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Library</h2>
<button type="button" data-action="hide-create-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<button type="button" data-action="hide-create-modal" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<form id="create-library-form">
<input type="hidden" id="library-id" name="id"/>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Name</label>
<input type="text" name="name" placeholder="My Ebook Library" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
<input type="text" name="name" placeholder="My Ebook Library" class="input" required/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Description</label>
<textarea name="description" placeholder="Optional description" rows="3" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"></textarea>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
<textarea name="description" placeholder="Optional description" rows="3" class="input"></textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Type</label>
<select name="type" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
<option value="ebooks">📚 Ebooks</option>
<option value="comics">📖 Comics</option>
<option value="manga">🗾 Manga</option>
<div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
<select name="type" class="input" required>
<option value="ebooks">Ebooks</option>
<option value="comics">Comics</option>
<option value="manga">Manga</option>
</select>
</div>
<div class="flex justify-end space-x-3">
<button type="button" data-action="hide-create-modal" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create</button>
<button type="button" data-action="hide-create-modal" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
Create
</button>
</div>
</form>
</div>
</div>
<!-- Folder Browser Modal -->
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
<button type="button" data-action="browse-cancel" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<button type="button" data-action="browse-cancel" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<div id="folder-browser-content">
<!-- Directory listings will be rendered here -->
@@ -134,18 +167,23 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
</div>
</div>
<!-- Delete Library Confirmation Modal -->
<div id="delete-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div id="delete-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2>
<button type="button" data-action="hide-delete-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<button type="button" data-action="hide-delete-modal" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<div id="delete-modal-content" class="mb-6" style="color: var(--text-primary)">
<!-- Dynamic content will be injected here -->
</div>
<div class="flex justify-end space-x-3">
<button type="button" data-action="hide-delete-modal" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="button" data-action="confirm-delete" class="btn-primary px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600">Delete</button>
<button type="button" data-action="hide-delete-modal" class="btn btn-secondary">Cancel</button>
<button type="button" data-action="confirm-delete" class="btn btn-danger">
@Icon("trash", "h-4 w-4")
Delete
</button>
</div>
</div>
</div>
+143 -27
View File
@@ -37,7 +37,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -45,161 +45,277 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"w-full\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><a href=\"/admin\" class=\"btn-secondary px-4 py-2 rounded-lg font-medium\">← Back to Dashboard</a> <button data-action=\"show-create-modal\" class=\"btn-primary px-4 py-2 rounded-lg font-medium\">+ Create Library</button></div><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Library Management</h1><p style=\"color: var(--text-secondary)\">Manage libraries and configure media scanning</p></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-8\"><!-- Libraries Section --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Libraries</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Manage media libraries and their folders</p><div id=\"libraries-list\" class=\"space-y-3 mb-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap mb-4\"><a href=\"/admin\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Dashboard</a> <button data-action=\"show-create-modal\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Create Library</button></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: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("library", "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, 6, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Library Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage libraries and configure media scanning</p></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6\"><!-- Libraries Section --><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("library", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Libraries</h3></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Manage media libraries and their folders</p><div id=\"libraries-list\" class=\"space-y-3 mb-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(libraries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p style=\"color: var(--text-secondary)\" class=\"text-center py-8\">No libraries yet. Create your first library to get started.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<p class=\"text-center py-8 text-sm\" style=\"color: var(--text-secondary)\">No libraries yet. Create your first library to get started.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
for _, library := range libraries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"p-4 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border)\"><div class=\"flex justify-between items-start mb-2\"><div><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"p-4 rounded-xl border transition-colors hover:bg-surface-hover\" style=\"background-color: var(--bg-primary); border-color: var(--border)\"><div class=\"flex justify-between items-start gap-3 mb-2\"><div class=\"min-w-0\"><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 44, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h4>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h4>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if library.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 46, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 56, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"inline-block px-2 py-1 text-xs rounded\" style=\"background-color: var(--accent); color: var(--bg-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"chip mt-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("tag", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 49, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 60, Col: 33}
}
_, 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, 10, "</span></div><div class=\"flex space-x-2\"><button data-library-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></div><div class=\"flex flex-wrap gap-1 shrink-0\"><button data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 53, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 64, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-action=\"show-folders\" class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--bg-secondary); color: var(--text-primary)\">Folders</button> <button data-library-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-action=\"show-folders\" class=\"btn btn-secondary text-xs px-2.5 py-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("folder", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Folders</button> <button data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 68, Col: 50}
}
_, 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, 12, "\" data-action=\"edit\" class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: var(--bg-primary)\">Edit</button> <button data-library-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-action=\"edit\" class=\"btn btn-primary text-xs px-2.5 py-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Edit</button> <button data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 55, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 72, Col: 50}
}
_, 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, 13, "\" data-action=\"delete\" class=\"text-xs px-2 py-1 rounded text-red-500\">Delete</button></div></div><div id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-action=\"delete\" class=\"btn btn-danger text-xs px-2.5 py-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Delete</button></div></div><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-folders-" + library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 58, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 78, Col: 53}
}
_, 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, 14, "\" class=\"hidden mt-3 space-y-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"hidden mt-3 space-y-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div><!-- User Library Visibility Section --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Library Visibility</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Control which libraries are visible to users</p><div id=\"visibility-controls\" class=\"space-y-4\"><!-- Visibility controls will be loaded here --></div></div></div><!-- User Visibility Management --><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">User Library Access</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Manage individual user access to specific libraries</p><div class=\"mb-4\"><select id=\"user-select\" onchange=\"loadUserVisibility()\" class=\"px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\"><option value=\"\">Select a user...</option> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div><!-- User Library Visibility Section --><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Library Visibility</h3></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Control which libraries are visible to users</p><div id=\"visibility-controls\" class=\"space-y-4\"><!-- Visibility controls will be loaded here --></div></div></div><!-- User Visibility Management --><div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">User Library Access</h3></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Manage individual user access to specific libraries</p><div class=\"mb-4\"><select id=\"user-select\" onchange=\"loadUserVisibility()\" class=\"input w-auto\"><option value=\"\">Select a user...</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, user := range users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"{ user.ID }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<option value=\"{ user.ID }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 107, Col: 53}
}
_, 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, " (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 107, Col: 69}
}
_, 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, ")</option>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</select></div><div id=\"user-libraries\" class=\"space-y-3\"><!-- User library checkboxes will be loaded here --></div></div></div></main></div><!-- Create Library Modal --><div id=\"create-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Library</h2><button type=\"button\" data-action=\"hide-create-modal\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><form id=\"create-library-form\"><input type=\"hidden\" id=\"library-id\" name=\"id\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Library Name</label> <input type=\"text\" name=\"name\" placeholder=\"My Ebook Library\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Description</label> <textarea name=\"description\" placeholder=\"Optional description\" rows=\"3\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\"></textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Library Type</label> <select name=\"type\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required><option value=\"ebooks\">📚 Ebooks</option> <option value=\"comics\">📖 Comics</option> <option value=\"manga\">🗾 Manga</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" data-action=\"hide-create-modal\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Create</button></div></form></div></div><!-- Folder Browser Modal --><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" data-action=\"browse-cancel\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div id=\"folder-browser-content\"><!-- Directory listings will be rendered here --></div></div></div><!-- Delete Library Confirmation Modal --><div id=\"delete-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Delete Library</h2><button type=\"button\" data-action=\"hide-delete-modal\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div id=\"delete-modal-content\" class=\"mb-6\" style=\"color: var(--text-primary)\"><!-- Dynamic content will be injected here --></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" data-action=\"hide-delete-modal\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"button\" data-action=\"confirm-delete\" class=\"btn-primary px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600\">Delete</button></div></div></div><script src=\"/static/htmx.min.js\"></script></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</select></div><div id=\"user-libraries\" class=\"space-y-3\"><!-- User library checkboxes will be loaded here --></div></div></div></main></div><!-- Create Library Modal --><div id=\"create-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Library</h2><button type=\"button\" data-action=\"hide-create-modal\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 29, "</button></div><form id=\"create-library-form\"><input type=\"hidden\" id=\"library-id\" name=\"id\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" name=\"name\" placeholder=\"My Ebook Library\" class=\"input\" required></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" placeholder=\"Optional description\" rows=\"3\" class=\"input\"></textarea></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Type</label> <select name=\"type\" class=\"input\" required><option value=\"ebooks\">Ebooks</option> <option value=\"comics\">Comics</option> <option value=\"manga\">Manga</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" data-action=\"hide-create-modal\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Create</button></div></form></div></div><!-- Folder Browser Modal --><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" data-action=\"browse-cancel\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 31, "</button></div><div id=\"folder-browser-content\"><!-- Directory listings will be rendered here --></div></div></div><!-- Delete Library Confirmation Modal --><div id=\"delete-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Delete Library</h2><button type=\"button\" data-action=\"hide-delete-modal\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 32, "</button></div><div id=\"delete-modal-content\" class=\"mb-6\" style=\"color: var(--text-primary)\"><!-- Dynamic content will be injected here --></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" data-action=\"hide-delete-modal\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" data-action=\"confirm-delete\" class=\"btn btn-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Delete</button></div></div></div><script src=\"/static/htmx.min.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+93 -72
View File
@@ -1,4 +1,3 @@
package templates
templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) {
@@ -12,83 +11,105 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
<body x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
@Header(user, "/admin/libraries/"+libraryID)
<main class="flex-1 p-8">
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-3xl font-bold mb-2">Processing Issues</h1>
<p class="text-gray-600">Items that couldn't be processed in this library</p>
<div class="mx-auto max-w-4xl">
<div class="mb-8">
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
<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: var(--accent-muted); color: var(--accent);">
@Icon("alert", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Processing Issues</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
</div>
<a href="/admin/libraries/{ libraryID }" class="btn btn-secondary">
@Icon("arrow-left", "h-4 w-4")
Back to Library
</a>
</div>
<a href="/admin/libraries/{ libraryID }" class="btn-secondary px-4 py-2 rounded-lg">
Back to Library
</a>
</div>
</div>
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
if stats.ErrorCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-red-500">
<h3 class="text-lg font-semibold text-red-600 mb-2">Errors</h3>
<p class="text-3xl font-bold">{ stats.ErrorCount }</p>
</div>
}
if stats.WarningCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-yellow-500">
<h3 class="text-lg font-semibold text-yellow-600 mb-2">Warnings</h3>
<p class="text-3xl font-bold">{ stats.WarningCount }</p>
</div>
}
if stats.InfoCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-blue-500">
<h3 class="text-lg font-semibold text-blue-600 mb-2">Info</h3>
<p class="text-3xl font-bold">{ stats.InfoCount }</p>
</div>
}
</div>
}
if len(issues) == 0 {
<div class="card p-8 rounded-lg text-center">
<p class="text-gray-600">No processing issues found for this library.</p>
</div>
} else {
<!-- Issues List -->
<div class="space-y-4">
for _, issue := range issues {
<div class="card p-6 rounded-lg">
<div class="flex justify-between items-start mb-4">
<div class="flex-1">
<h4 class="text-lg font-semibold mb-2">{ issue.Title }</h4>
<p class="text-gray-700 mb-3">{ issue.IssueDescription }</p>
<div class="text-sm text-gray-500 space-y-1">
<p><strong>Type:</strong> { issue.IssueType }</p>
<p><strong>Format:</strong> { issue.FormatGroup }</p>
<p><strong>File:</strong> { issue.FilePath }</p>
<p><strong>Library:</strong> { issue.LibraryTypeName }</p>
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
if stats.ErrorCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
@Icon("x-circle", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
</div>
}
if stats.WarningCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-warning);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
</div>
}
if stats.InfoCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-info);">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);">
@Icon("info", "h-5 w-5")
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
</div>
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
</div>
}
</div>
}
if len(issues) == 0 {
<div class="card p-8 text-center">
<span class="grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-6 w-6")
</span>
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
</div>
} else {
<!-- Issues List -->
<div class="space-y-4">
for _, issue := range issues {
<div class="card p-6">
<div class="flex justify-between items-start gap-4 mb-4">
<div class="flex-1 min-w-0">
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
</div>
</div>
<div class="ml-2 shrink-0">
if issue.Severity == "error" {
<span class="badge status-failed">{ issue.Severity }</span>
} else if issue.Severity == "warning" {
<span class="badge status-pending">{ issue.Severity }</span>
} else {
<span class="badge status-processing">{ issue.Severity }</span>
}
</div>
</div>
<div class="ml-4">
<span
class="inline-block px-3 py-1 text-sm rounded-full font-medium"
style="background-color: var(--accent); color: white;"
>
{ issue.Severity }
</span>
<div class="flex gap-3 mt-4">
if issue.Severity == "warning" || issue.Severity == "info" {
<button
@click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Dismiss
</button>
}
</div>
</div>
<div class="flex gap-3 mt-4">
if issue.Severity == "warning" || issue.Severity == "info" {
<button
@click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
class="btn-secondary px-4 py-2 rounded text-sm"
>
Dismiss
</button>
}
</div>
</div>
}
</div>
}
}
</div>
}
</div>
</main>
</body>
</html>
+147 -46
View File
@@ -1,7 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -38,200 +37,302 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"flex-1 p-8\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><div><h1 class=\"text-3xl font-bold mb-2\">Processing Issues</h1><p class=\"text-gray-600\">Items that couldn't be processed in this library</p></div><a href=\"/admin/libraries/{ libraryID }\" class=\"btn-secondary px-4 py-2 rounded-lg\">← Back to Library</a></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"flex-1 p-8\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap mb-4\"><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: var(--accent-muted); color: var(--accent);\">")
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, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div><a href=\"/admin/libraries/{ libraryID }\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Library</a></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if stats.ErrorCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"card p-6 rounded-lg border-l-4 border-red-500\"><h3 class=\"text-lg font-semibold text-red-600 mb-2\">Errors</h3><p class=\"text-3xl font-bold\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-danger);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("x-circle", "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, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Errors</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 32, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 41, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if stats.WarningCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card p-6 rounded-lg border-l-4 border-yellow-500\"><h3 class=\"text-lg font-semibold text-yellow-600 mb-2\">Warnings</h3><p class=\"text-3xl font-bold\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-warning);\"><div class=\"flex items-center gap-2 mb-2\" 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, 10, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Warnings</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 38, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 50, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"card p-6 rounded-lg border-l-4 border-blue-500\"><h3 class=\"text-lg font-semibold text-blue-600 mb-2\">Info</h3><p class=\"text-3xl font-bold\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"stat-card\" style=\"border-left: 4px solid var(--status-info);\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--status-info);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "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, 13, "<h3 class=\"text-sm font-semibold uppercase tracking-wide\">Info</h3></div><p class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 44, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 59, Col: 91}
}
_, 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, 9, "</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(issues) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-8 rounded-lg text-center\"><p class=\"text-gray-600\">No processing issues found for this library.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card p-8 text-center\"><span class=\"grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<!-- Issues List --> <div class=\"space-y-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<!-- Issues List --> <div class=\"space-y-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, issue := range issues {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card p-6 rounded-lg\"><div class=\"flex justify-between items-start mb-4\"><div class=\"flex-1\"><h4 class=\"text-lg font-semibold mb-2\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"card p-6\"><div class=\"flex justify-between items-start gap-4 mb-4\"><div class=\"flex-1 min-w-0\"><h4 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 60, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 98}
}
_, 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, 14, "</h4><p class=\"text-gray-700 mb-3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 61, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 96}
}
_, 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, 15, "</p><div class=\"text-sm text-gray-500 space-y-1\"><p><strong>Type:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 63, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 81, Col: 140}
}
_, 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, 16, "</p><p><strong>Format:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 64, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 82, Col: 144}
}
_, 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, 17, "</p><p><strong>File:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 65, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 83, Col: 139}
}
_, 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, 18, "</p><p><strong>Library:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 66, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 149}
}
_, 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, 19, "</p></div></div><div class=\"ml-4\"><span class=\"inline-block px-3 py-1 text-sm rounded-full font-medium\" style=\"background-color: var(--accent); color: white;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p></div></div><div class=\"ml-2 shrink-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 27}
}
_, 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, 20, "</span></div></div><div class=\"flex gap-3 mt-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "warning" || issue.Severity == "info" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button @click=\"dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')\" class=\"btn-secondary px-4 py-2 rounded text-sm\">Dismiss</button>")
if issue.Severity == "error" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"badge status-failed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 62}
}
_, 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, 27, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if issue.Severity == "warning" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge status-pending\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 91, Col: 63}
}
_, 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, 29, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge status-processing\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 93, Col: 66}
}
_, 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, 31, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></div><div class=\"flex gap-3 mt-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if issue.Severity == "warning" || issue.Severity == "info" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button @click=\"dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Dismiss</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+41 -26
View File
@@ -11,55 +11,67 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
</head>
<body class="theme-{ user.Theme }" x-data="adminSettings">
@Header(user, "/admin/settings")
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
<div class="flex">
@AdminSidebar(user, "/admin/settings")
<main class="flex-1 p-8">
<div class="max-w-4xl">
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
Back to Dashboard
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
<a href="/admin" class="btn btn-secondary">
@Icon("arrow-left", "h-4 w-4")
Back to Dashboard
</a>
</div>
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">System Settings</h1>
<p style="color: var(--text-secondary)">Configure your Bookhoard instance</p>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("settings", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">System Settings</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Configure your Bookhoard instance</p>
</div>
if errorMessage != "" {
<div class="mb-6 p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--error); color: var(--error);">
{ errorMessage }
<div class="mb-6 p-4 rounded-xl border flex items-start gap-3" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); border-color: var(--status-danger); color: var(--status-danger);">
@Icon("alert", "h-5 w-5 shrink-0 mt-0.5")
<span>{ errorMessage }</span>
</div>
}
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
<div class="card p-6">
<div class="flex items-center gap-2 mb-6">
@Icon("globe", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Base URL</h3>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Base URL</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Base URL</label>
<input
type="url"
name="base_url"
value={ systemConfig["base_url"] }
placeholder="https://books.example.com"
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
required
/>
<p class="text-sm mt-1" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
<button type="submit" class="btn btn-primary">
@Icon("save", "h-4 w-4")
Save Settings
</button>
</div>
</div>
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">System Defaults</h3>
<div class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-6">
@Icon("clock", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">System Defaults</h3>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default Timezone</label>
<select
name="default_timezone"
id="default_timezone"
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="UTC" selected?={ systemConfig["default_timezone"] == "UTC" }>UTC (UTC+0)</option>
<option value="Pacific/Honolulu" selected?={ systemConfig["default_timezone"] == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
@@ -86,16 +98,19 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
<option value="Australia/Sydney" selected?={ systemConfig["default_timezone"] == "Australia/Sydney" }>Australian Eastern (UTC+10/+11)</option>
<option value="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>New Zealand (UTC+12/+13)</option>
</select>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
<p class="text-sm mt-2" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
</div>
</div>
</form>
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">URL Paths</h3>
<div class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-4">
@Icon("external", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">URL Paths</h3>
</div>
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
<p><strong>OPDS:</strong> { systemConfig["base_url"] }/opds</p>
<p><strong>API:</strong> { systemConfig["base_url"] }/api</p>
<p><strong>Device Sync:</strong> { systemConfig["base_url"] }/api/sync</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">OPDS:</span> { systemConfig["base_url"] }/opds</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">API:</span> { systemConfig["base_url"] }/api</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Device Sync:</span> { systemConfig["base_url"] }/api/sync</p>
</div>
</div>
</div>
+141 -85
View File
@@ -37,7 +37,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -45,322 +45,378 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><a href=\"/admin\" class=\"btn-secondary px-4 py-2 rounded-lg font-medium\">← Back to Dashboard</a></div><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">System Settings</h1><p style=\"color: var(--text-secondary)\">Configure your Bookhoard instance</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap mb-4\"><a href=\"/admin\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Dashboard</a></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: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("settings", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">System Settings</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Configure your Bookhoard instance</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"mb-6 p-4 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--error); color: var(--error);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"mb-6 p-4 rounded-xl border flex items-start gap-3\" style=\"background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); border-color: var(--status-danger); color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-5 w-5 shrink-0 mt-0.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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 29, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 36, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Base URL</h3><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Base URL</label> <input type=\"url\" name=\"base_url\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("globe", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Base URL</h3></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Base URL</label> <input type=\"url\" name=\"base_url\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 40, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 50, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" placeholder=\"https://books.example.com\" class=\"w-full px-4 py-2 rounded-lg border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" required><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn-primary px-6 py-2 rounded-lg font-medium\">Save Settings</button></div></div><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">System Defaults</h3><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Default Timezone</label> <select name=\"default_timezone\" id=\"default_timezone\" class=\"w-full px-4 py-2 rounded-lg border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"UTC\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" placeholder=\"https://books.example.com\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Save Settings</button></div></div><div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("clock", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">System Defaults</h3></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Default Timezone</label> <select name=\"default_timezone\" id=\"default_timezone\" class=\"input\"><option value=\"UTC\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "UTC" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "America/Denver" {
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "America/Phoenix" {
if systemConfig["default_timezone"] == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "America/Chicago" {
if systemConfig["default_timezone"] == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "America/New_York" {
if systemConfig["default_timezone"] == "America/Denver" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
if systemConfig["default_timezone"] == "America/Phoenix" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Europe/London" {
if systemConfig["default_timezone"] == "America/Chicago" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Europe/Paris" {
if systemConfig["default_timezone"] == "America/New_York" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Europe/Helsinki" {
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Europe/Moscow" {
if systemConfig["default_timezone"] == "Europe/London" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Asia/Tehran" {
if systemConfig["default_timezone"] == "Europe/Paris" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Asia/Dubai" {
if systemConfig["default_timezone"] == "Europe/Helsinki" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Asia/Karachi" {
if systemConfig["default_timezone"] == "Europe/Moscow" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Asia/Kolkata" {
if systemConfig["default_timezone"] == "Asia/Tehran" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Asia/Dhaka" {
if systemConfig["default_timezone"] == "Asia/Dubai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Asia/Bangkok" {
if systemConfig["default_timezone"] == "Asia/Karachi" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Asia/Shanghai" {
if systemConfig["default_timezone"] == "Asia/Kolkata" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Asia/Tokyo" {
if systemConfig["default_timezone"] == "Asia/Dhaka" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Australia/Darwin" {
if systemConfig["default_timezone"] == "Asia/Bangkok" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Australia/Sydney" {
if systemConfig["default_timezone"] == "Asia/Shanghai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Pacific/Auckland" {
if systemConfig["default_timezone"] == "Asia/Tokyo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">New Zealand (UTC+12/+13)</option></select><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Default timezone for users who haven't set their own.</p></div></div></form><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">URL Paths</h3><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><strong>OPDS:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Australia/Darwin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Australia/Sydney" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if systemConfig["default_timezone"] == "Pacific/Auckland" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">New Zealand (UTC+12/+13)</option></select><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Default timezone for users who haven't set their own.</p></div></div></form><div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("external", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">URL Paths</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">OPDS:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 96, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 145}
}
_, 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, 56, "/opds</p><p><strong>API:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/opds</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">API:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 97, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 112, Col: 144}
}
_, 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, 57, "/api</p><p><strong>Device Sync:</strong> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "/api</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Device Sync:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 98, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 113, Col: 152}
}
_, 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, 58, "/api/sync</p></div></div></div></main></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "/api/sync</p></div></div></div></main></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+22 -13
View File
@@ -1,23 +1,32 @@
package templates
templ AdminSidebar(user User, currentPath string) {
<aside class="w-64 border-r" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="p-6">
<h2 class="text-lg font-semibold mb-6" style="color: var(--text-primary)">Admin Panel</h2>
<nav class="space-y-2">
<a href="/admin" class={activeClass(currentPath, "/admin")} style="color: var(--text-primary)">
🏠 Dashboard
<aside class="w-64 shrink-0 self-start sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto border-r" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="p-5">
<div class="flex items-center gap-2 mb-6 px-2">
<span class="grid place-items-center h-8 w-8 rounded-lg shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("shield", "h-4 w-4")
</span>
<h2 class="text-xs font-bold uppercase tracking-wide" style="color: var(--text-primary)">Admin Panel</h2>
</div>
<nav class="space-y-0.5">
<a href="/admin" class={ activeClass(currentPath, "/admin") }>
@Icon("grid", "h-5 w-5 shrink-0")
<span>Dashboard</span>
</a>
<a href="/admin/users" class={activeClass(currentPath, "/admin/users")} style="color: var(--text-primary)">
👤 User Administration
<a href="/admin/users" class={ activeClass(currentPath, "/admin/users") }>
@Icon("users", "h-5 w-5 shrink-0")
<span>Users</span>
</a>
<a href="/admin/library" class={activeClass(currentPath, "/admin/library")} style="color: var(--text-primary)">
📚 Library Management
<a href="/admin/library" class={ activeClass(currentPath, "/admin/library") }>
@Icon("library", "h-5 w-5 shrink-0")
<span>Library</span>
</a>
<a href="/admin/settings" class={activeClass(currentPath, "/admin/settings")} style="color: var(--text-primary)">
⚙️ System Settings
<a href="/admin/settings" class={ activeClass(currentPath, "/admin/settings") }>
@Icon("settings", "h-5 w-5 shrink-0")
<span>Settings</span>
</a>
</nav>
</div>
</aside>
}
}
+49 -9
View File
@@ -29,7 +29,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<aside class=\"w-64 border-r\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"p-6\"><h2 class=\"text-lg font-semibold mb-6\" style=\"color: var(--text-primary)\">Admin Panel</h2><nav class=\"space-y-2\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<aside class=\"w-64 shrink-0 self-start sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto border-r\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"p-5\"><div class=\"flex items-center gap-2 mb-6 px-2\"><span class=\"grid place-items-center h-8 w-8 rounded-lg shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("shield", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span><h2 class=\"text-xs font-bold uppercase tracking-wide\" style=\"color: var(--text-primary)\">Admin Panel</h2></div><nav class=\"space-y-0.5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -38,7 +46,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"/admin\" class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a href=\"/admin\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -51,7 +59,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" style=\"color: var(--text-primary)\">🏠 Dashboard</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("grid", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>Dashboard</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -60,7 +76,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"/admin/users\" class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/users\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -73,7 +89,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"color: var(--text-primary)\">👤 User Administration</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span>Users</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -82,7 +106,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/library\" class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"/admin/library\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -95,7 +119,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" style=\"color: var(--text-primary)\">📚 Library Management</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("library", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span>Library</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -104,7 +136,7 @@ func AdminSidebar(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/admin/settings\" class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/admin/settings\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -117,7 +149,15 @@ func AdminSidebar(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" style=\"color: var(--text-primary)\">⚙️ System Settings</a></nav></div></aside>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("settings", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span>Settings</span></a></nav></div></aside>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+74 -2
View File
@@ -37,7 +37,7 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -45,7 +45,79 @@ func Admin(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Dashboard</h1><p style=\"color: var(--text-secondary)\">Overview of your Bookhoard library and settings</p></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">📖</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Library</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded\">View Library</a></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">👁️</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Watch Status</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Auto-detecting new files</p></div></div><div id=\"watch-status\" class=\"mt-4 text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full bg-green-500 mr-2\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn-primary p-4 rounded-lg text-left\"><div class=\"font-medium\">Rescan Library</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Re-scan existing files and fix metadata</div></button> <a href=\"/admin/library\" class=\"btn-secondary p-4 rounded-lg text-left block\"><div class=\"font-medium\">Manage Libraries and Folders</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Add or remove libraries and scan directories</div></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">📚 Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"p-2 hover:bg-gray-700 rounded\">✕</button></div><!-- Overall Progress --><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full bg-gray-700 rounded-full h-3\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><!-- Per-Library Progress --><div id=\"library-progress-list\" class=\"space-y-3\"><!-- Dynamically populated --></div><!-- Results Summary --><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2\" style=\"color: var(--text-primary)\">✅ Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"><!-- Results populated by JS --></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn-primary px-4 py-2 rounded-lg\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Dismiss</button></div></div></div></div></main></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("grid", "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, 4, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Dashboard</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Overview of your Bookhoard library and settings</p></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-6\"><div class=\"stat-card\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Library</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"btn btn-secondary mt-4 text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "View Library</a></div><div class=\"stat-card\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("sync", "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=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Watch Status</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Auto-detecting new files</p></div></div><div id=\"watch-status\" class=\"mt-4 text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full mr-2\" style=\"background-color: var(--status-success);\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn btn-primary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "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, 8, "Rescan Library</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and fix metadata</span></button> <a href=\"/admin/library\" class=\"btn btn-secondary py-4 flex-col items-start gap-1\"><span class=\"flex items-center gap-2 font-medium\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("library", "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, 9, "Manage Libraries</span> <span class=\"text-xs font-normal opacity-80\">Add or remove libraries and scan directories</span></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"card hidden mt-6 p-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "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, 10, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 11, "</button></div><!-- Overall Progress --><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full rounded-full h-3\" style=\"background-color: var(--surface-hover);\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><!-- Per-Library Progress --><div id=\"library-progress-list\" class=\"space-y-3\"><!-- Dynamically populated --></div><!-- Results Summary --><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-xl border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2 flex items-center gap-2\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "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, 12, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"><!-- Results populated by JS --></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn btn-primary\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn btn-secondary\">Dismiss</button></div></div></div></div></main></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+119 -109
View File
@@ -13,119 +13,129 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
@Header(currentUser, "/admin/users")
<!-- Modal Container (populated by HTMX) -->
<div id="modal-container"></div>
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
<div class="flex">
@AdminSidebar(currentUser, "/admin/users")
<main class="flex-1 p-8">
<div class="w-full">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1>
<p style="color: var(--text-secondary)">Manage user accounts and permissions</p>
</div>
<!-- Users Table -->
<div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
<table class="w-full">
<thead style="background-color: var(--bg-primary)">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Actions</th>
</tr>
</thead>
<tbody class="divide-y" style="divide-color: var(--border)">
for _, user := range users {
<tr id={ "user-" + user.ID } class="hover:bg-opacity-50" style="transition: background-color 0.2s;">
<!-- Username -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div>
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
if user.ID == currentUser.ID {
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span>
<div class="max-w-5xl">
<div class="mb-8">
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("users", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">User Management</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Manage user accounts and permissions</p>
</div>
<!-- Users Table -->
<div class="card overflow-hidden">
<table class="w-full">
<thead style="background-color: var(--bg-primary)">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Username</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Email</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Role</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Created</th>
<th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Actions</th>
</tr>
</thead>
<tbody class="divide-y" style="divide-color: var(--border)">
for _, user := range users {
<tr id={ "user-" + user.ID } class="transition-colors hover:bg-surface-hover">
<!-- Username -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class="grid place-items-center h-8 w-8 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("user", "h-4 w-4")
</span>
<div>
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
if user.ID == currentUser.ID {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
}
</div>
</div>
</td>
<!-- Email -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
</td>
<!-- Role Toggle (with last-admin protection) -->
<td class="px-6 py-4 whitespace-nowrap">
if user.Role == "admin" && adminCount == 1 {
<!-- Last admin - disabled -->
<div class="relative">
<select
disabled
class="input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed"
title="Cannot demote the last admin"
>
<option value="user">User</option>
<option value="admin" selected>Admin</option>
</select>
</div>
} else {
<select
hx-put={ "/api/auth/profile/" + user.ID }
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target={ "#role-result-" + user.ID }
hx-swap="innerHTML"
name="role"
class="input w-auto py-1 pr-7 text-xs"
onchange="this.dispatchEvent(new Event('htmx:trigger'))"
hx-trigger="change"
hx-vals='{"role": this.value}'
>
<option value="user" selected?={ user.Role == "user" }>User</option>
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
</select>
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
}
</td>
<!-- Created -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
</td>
<!-- Actions -->
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="inline-flex items-center gap-1">
<button
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn btn-secondary text-xs px-2.5 py-1"
>
@Icon("edit", "h-4 w-4")
Edit
</button>
if user.Role == "admin" && adminCount == 1 {
<button
disabled
class="btn btn-danger text-xs px-2.5 py-1"
title="Cannot delete the last admin"
>
@Icon("trash", "h-4 w-4")
Delete
</button>
} else {
<button
hx-delete={ "/api/auth/profile/" + user.ID }
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target={ "#user-" + user.ID }
hx-swap="outerHTML swap:0.5s"
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
class="btn btn-danger text-xs px-2.5 py-1"
>
@Icon("trash", "h-4 w-4")
Delete
</button>
}
</div>
</div>
</td>
<!-- Email -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
</td>
<!-- Role Toggle (with last-admin protection) -->
<td class="px-6 py-4 whitespace-nowrap">
if user.Role == "admin" && adminCount == 1 {
<!-- Last admin - disabled -->
<div class="relative">
<select
disabled
class="text-sm rounded px-2 py-1 cursor-not-allowed opacity-50"
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
title="Cannot demote the last admin"
>
<option value="user">User</option>
<option value="admin" selected>Admin</option>
</select>
</div>
} else {
<select
hx-put={ "/api/auth/profile/" + user.ID }
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target={ "#role-result-" + user.ID }
hx-swap="innerHTML"
name="role"
class="text-sm rounded px-2 py-1"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
onchange="this.dispatchEvent(new Event('htmx:trigger'))"
hx-trigger="change"
hx-vals='{"role": this.value}'
>
<option value="user" selected?={ user.Role == "user" }>User</option>
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
</select>
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
}
</td>
<!-- Created -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
</td>
<!-- Actions -->
<td class="px-6 py-4 whitespace-nowrap text-right">
<button
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
hx-target="#modal-container"
hx-swap="innerHTML"
class="text-sm px-3 py-1 rounded mr-2"
style="background-color: var(--accent); color: white;"
>
Edit
</button>
if user.Role == "admin" && adminCount == 1 {
<button
disabled
class="text-sm px-3 py-1 rounded cursor-not-allowed opacity-50"
style="background-color: #dc2626; color: white;"
title="Cannot delete the last admin"
>
Delete
</button>
} else {
<button
hx-delete={ "/api/auth/profile/" + user.ID }
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target={ "#user-" + user.ID }
hx-swap="outerHTML swap:0.5s"
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
class="text-sm px-3 py-1 rounded"
style="background-color: #dc2626; color: white;"
>
Delete
</button>
}
</td>
</tr>
}
</tbody>
</table>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</main>
</div>
+83 -43
View File
@@ -37,7 +37,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><div class=\"flex\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -45,207 +45,247 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"w-full\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">User Management</h1><p style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card rounded-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><table class=\"w-full\"><thead style=\"background-color: var(--bg-primary)\"><tr><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Username</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Email</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Role</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Created</th><th class=\"px-6 py-3 text-right text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Actions</th></tr></thead> <tbody class=\"divide-y\" style=\"divide-color: var(--border)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("users", "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, 4, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">User Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card overflow-hidden\"><table class=\"w-full\"><thead style=\"background-color: var(--bg-primary)\"><tr><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Username</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Email</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Role</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Created</th><th class=\"px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Actions</th></tr></thead> <tbody class=\"divide-y\" style=\"divide-color: var(--border)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, user := range users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 43, Col: 36}
}
_, 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, "\" class=\"hover:bg-opacity-50\" style=\"transition: background-color 0.2s;\"><!-- Username --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"flex items-center\"><div><div class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"transition-colors hover:bg-surface-hover\"><!-- Username --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"flex items-center gap-2\"><span class=\"grid place-items-center h-8 w-8 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("user", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><div><div class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 51, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.ID == currentUser.ID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: white;\">You</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" 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(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 51, Col: 79}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 60, Col: 80}
}
_, 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, 9, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "admin" && adminCount == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!-- Last admin - disabled --> <div class=\"relative\"><select disabled class=\"text-sm rounded px-2 py-1 cursor-not-allowed opacity-50\" style=\"background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<!-- Last admin - disabled --> <div class=\"relative\"><select disabled class=\"input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<select hx-put=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<select hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 70, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 72, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 80, Col: 51}
}
_, 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, 13, "\" hx-swap=\"innerHTML\" name=\"role\" class=\"text-sm rounded px-2 py-1\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\" onchange=\"this.dispatchEvent(new Event('htmx:trigger'))\" hx-trigger=\"change\" hx-vals='{\"role\": this.value}'><option value=\"user\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-swap=\"innerHTML\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\" onchange=\"this.dispatchEvent(new Event('htmx:trigger'))\" hx-trigger=\"change\" hx-vals='{\"role\": this.value}'><option value=\"user\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Admin</option></select><div id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Admin</option></select><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 84, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 91, Col: 47}
}
_, 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, 18, "\" class=\"text-xs mt-1\"></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" class=\"text-xs mt-1\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 89, Col: 125}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 96, Col: 126}
}
_, 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, 20, "</div></td><!-- Actions --><td class=\"px-6 py-4 whitespace-nowrap text-right\"><button hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></td><!-- Actions --><td class=\"px-6 py-4 whitespace-nowrap text-right\"><div class=\"inline-flex items-center gap-1\"><button hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 94, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 102, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"text-sm px-3 py-1 rounded mr-2\" style=\"background-color: var(--accent); color: white;\">Edit</button> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-xs px-2.5 py-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Edit</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "admin" && adminCount == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button disabled class=\"text-sm px-3 py-1 rounded cursor-not-allowed opacity-50\" style=\"background-color: #dc2626; color: white;\" title=\"Cannot delete the last admin\">Delete</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<button disabled class=\"btn btn-danger text-xs px-2.5 py-1\" title=\"Cannot delete the last admin\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Delete</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button hx-delete=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 113, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 121, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 123, Col: 45}
}
_, 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, 25, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"text-sm px-3 py-1 rounded\" style=\"background-color: #dc2626; color: white;\">Delete</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"btn btn-danger text-xs px-2.5 py-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Delete</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td></tr>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div></main></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</tbody></table></div></div></main></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+35 -38
View File
@@ -12,78 +12,75 @@ templ Analytics(user User) {
</head>
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
@Header(user, "/analytics")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1>
<p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("chart", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Analytics</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Track your reading habits and device usage</p>
</div>
<!-- Date Range Picker -->
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex flex-wrap gap-4 items-center">
<div class="card p-4 mb-6">
<div class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Start Date</label>
<input
type="date"
id="start-date"
onchange="loadAnalytics()"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">End Date</label>
<input
type="date"
id="end-date"
onchange="loadAnalytics()"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div class="flex items-end">
<button @click="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
<div>
<button @click="loadAnalytics()" class="btn btn-primary">Update</button>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Books Read</h3>
<p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8" id="stats-container">
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Books Read</h3>
<p id="total-books" class="text-3xl font-bold" style="color: var(--accent);">-</p>
</div>
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3>
<p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Pages Read</h3>
<p id="total-pages" class="text-3xl font-bold" style="color: var(--accent);">-</p>
</div>
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3>
<p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Reading Time</h3>
<p id="reading-time" class="text-3xl font-bold" style="color: var(--accent);">-</p>
</div>
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
<div class="stat-card">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--accent);">-</p>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Daily Reading Chart -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Daily Reading Minutes</h3>
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Daily Reading Minutes</h3>
<div class="h-80">
<canvas id="daily-reading-chart"></canvas>
</div>
</div>
<!-- Device Usage Chart -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3>
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Device Usage</h3>
<div class="h-80">
<canvas id="device-usage-chart"></canvas>
</div>
</div>
</div>
<!-- Popular Books -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3>
<div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Most Read Books</h3>
<div id="popular-books" class="space-y-3">
<div class="text-center py-8" style="color: var(--text-secondary);">
<div class="loading-spinner mx-auto mb-4"></div>
+9 -1
View File
@@ -37,7 +37,15 @@ func Analytics(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">📊 Reading Analytics</h1><p style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><!-- Date Range Picker --><div class=\"card p-4 rounded-lg border mb-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div class=\"flex items-end\"><button @click=\"loadAnalytics()\" class=\"btn-primary px-6 py-2 rounded-lg\">Update</button></div></div></div><!-- Stats Cards --><div class=\"grid grid-cols-1 md:grid-cols-4 gap-6 mb-8\" id=\"stats-container\"><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div></div><!-- Charts Row --><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><!-- Daily Reading Chart --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><!-- Device Usage Chart --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><!-- Popular Books --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chart", "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, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Reading Analytics</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><div class=\"card p-4 mb-6\"><div class=\"flex flex-wrap gap-4 items-end\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div><button @click=\"loadAnalytics()\" class=\"btn btn-primary\">Update</button></div></div></div><div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8\" id=\"stats-container\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+82 -49
View File
@@ -2,64 +2,97 @@ package templates
templ APIExplorer(explorer APIExplorerData) {
if !explorer.IsLoggedIn {
// Show mode toggle and mock data
<div x-data="devices" class="border border-border rounded-lg p-6 mt-8 bg-background-secondary">
<div class="flex gap-2 mb-4">
<button class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer bg-accent text-white">Mock Data</button>
<button class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button>
<div x-data="devices" class="card p-6 mt-8 space-y-5">
<div class="flex items-center gap-2 flex-wrap">
if explorer.Endpoint.Method == "GET" {
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "DELETE" {
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "POST" {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
} else {
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
}
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
</div>
<div class="mb-4">
<div class="mb-4">
<select disabled class="w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent">
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
</select>
</div>
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body (Example)</h4>
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.RequestBody }</code></pre>
<div class="flex gap-2">
<button class="btn btn-primary">Mock Data</button>
<button class="btn btn-secondary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button>
</div>
<div class="mt-4">
<h4 class="text-text-secondary mb-2 text-sm font-medium">Response (Mock)</h4>
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.Response }</code></pre>
<div>
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body (Example)</p>
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.RequestBody }</code></pre>
</div>
<div class="flex gap-2 mt-4">
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
<div>
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Response (Mock)</p>
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.Response }</code></pre>
</div>
<div class="flex gap-2">
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Request
</button>
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Response
</button>
</div>
</div>
} else {
// Show interactive explorer with real execution
<div class="border border-border rounded-lg p-6 mt-8 bg-background-secondary" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
<div class="flex gap-2 mb-4">
<button class="mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
<button class="mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
<div class="card p-6 mt-8 space-y-5" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
<div class="flex items-center gap-2 flex-wrap">
if explorer.Endpoint.Method == "GET" {
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "DELETE" {
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "POST" {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
} else {
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
}
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
</div>
<div class="mb-4">
<div class="mb-4">
<select id="http-method" class="w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent">
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
</select>
<div class="flex gap-2">
<button class="mode-btn btn btn-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
<button class="mode-btn btn btn-primary text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
</div>
<div>
<label for="http-method" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Method</label>
<select id="http-method" class="input">
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
</select>
</div>
<div>
<label for="request-body" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body</label>
<textarea id="request-body" placeholder="Edit request body…" class="input min-h-[150px] font-mono">{ explorer.Endpoint.RequestBody }</textarea>
</div>
<button id="try-it-out" @click="tryDocEndpoint()" class="btn btn-primary">
@Icon("play", "h-4 w-4")
Try It Out
</button>
<div class="api-response hidden space-y-2">
<div class="flex justify-between text-sm">
<span id="response-status" class="font-semibold" style="color: var(--text-primary);"></span>
<span id="response-time" style="color: var(--text-secondary);"></span>
</div>
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body</h4>
<textarea id="request-body" placeholder="Edit request body..." class="w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent">{ explorer.Endpoint.RequestBody }</textarea>
<pre class="card p-4 overflow-x-auto"><code id="response-body" class="text-sm" style="color: var(--text-primary);"></code></pre>
</div>
<button id="try-it-out" @click="tryDocEndpoint()" class="bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90">Try It Out</button>
<div class="api-response mt-4 hidden">
<div class="flex justify-between mb-2 text-sm">
<span id="response-status" class="font-semibold"></span>
<span id="response-time" class="text-text-secondary"></span>
</div>
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-text-primary text-sm"></code></pre>
</div>
<div class="flex gap-2 mt-4 flex-wrap">
<button @click="copyDocRequest()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
<button @click="copyDocResponse()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
<button @click="generateDocCURL()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Generate cURL</button>
<div class="flex gap-2 flex-wrap">
<button @click="copyDocRequest()" class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Request
</button>
<button @click="copyDocResponse()" class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Response
</button>
<button @click="generateDocCURL()" class="btn btn-secondary text-sm">
@Icon("external", "h-4 w-4")
Generate cURL
</button>
</div>
</div>
}
+238 -50
View File
@@ -30,131 +30,319 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
}
ctx = templ.ClearChildren(ctx)
if !explorer.IsLoggedIn {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " <div x-data=\"devices\" class=\"border border-border rounded-lg p-6 mt-8 bg-background-secondary\"><div class=\"flex gap-2 mb-4\"><button class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer bg-accent text-white\">Mock Data</button> <button class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary opacity-50 cursor-not-allowed\" disabled>Login to Try Real</button></div><div class=\"mb-4\"><div class=\"mb-4\"><select disabled class=\"w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent\"><option value=\"GET\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"devices\" class=\"card p-6 mt-8 space-y-5\"><div class=\"flex items-center gap-2 flex-wrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "GET" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"badge status-completed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if explorer.Endpoint.Method == "DELETE" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"badge status-failed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if explorer.Endpoint.Method == "POST" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
}
_, 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, 7, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge status-processing\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
}
_, 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, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">GET</option> <option value=\"POST\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "POST" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">POST</option> <option value=\"PUT\"")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "PUT" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ">PUT</option> <option value=\"DELETE\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</code></div><div class=\"flex gap-2\"><button class=\"btn btn-primary\">Mock Data</button> <button class=\"btn btn-secondary opacity-50 cursor-not-allowed\" disabled>Login to Try Real</button></div><div><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Request Body (Example)</p><pre class=\"card p-4 overflow-x-auto\"><code class=\"text-sm\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "DELETE" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">DELETE</option></select></div><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Request Body (Example)</h4><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code class=\"text-text-primary text-sm\">")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 21, Col: 141}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</code></pre></div><div><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Response (Mock)</p><pre class=\"card p-4 overflow-x-auto\"><code class=\"text-sm\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</code></pre></div><div class=\"mt-4\"><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Response (Mock)</h4><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code class=\"text-text-primary text-sm\">")
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 25, Col: 138}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</code></pre></div><div class=\"flex gap-2\"><button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</code></pre></div><div class=\"flex gap-2 mt-4\"><button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Response</button></div></div>")
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Copy Response</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " <div class=\"border border-border rounded-lg p-6 mt-8 bg-background-secondary\" x-data=\"apiExplorerDoc\" x-init=\"initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')\"><div class=\"flex gap-2 mb-4\"><button class=\"mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm\" id=\"mock-btn\" @click=\"showDocMode('mock')\">Mock Data</button> <button class=\"mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm\" id=\"real-btn\" @click=\"showDocMode('real')\">Try It Out</button></div><div class=\"mb-4\"><div class=\"mb-4\"><select id=\"http-method\" class=\"w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent\"><option value=\"GET\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card p-6 mt-8 space-y-5\" x-data=\"apiExplorerDoc\" x-init=\"initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')\"><div class=\"flex items-center gap-2 flex-wrap\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "GET" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge status-completed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
}
_, 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, 18, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if explorer.Endpoint.Method == "DELETE" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"badge status-failed\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
}
_, 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, 20, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if explorer.Endpoint.Method == "POST" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
}
_, 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, 22, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"badge status-processing\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
}
_, 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, 24, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">GET</option> <option value=\"POST\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
}
_, 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, 26, "</code></div><div class=\"flex gap-2\"><button class=\"mode-btn btn btn-secondary text-sm\" id=\"mock-btn\" @click=\"showDocMode('mock')\">Mock Data</button> <button class=\"mode-btn btn btn-primary text-sm\" id=\"real-btn\" @click=\"showDocMode('real')\">Try It Out</button></div><div><label for=\"http-method\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Method</label> <select id=\"http-method\" class=\"input\"><option value=\"GET\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "GET" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">GET</option> <option value=\"POST\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "POST" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">POST</option> <option value=\"PUT\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">POST</option> <option value=\"PUT\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "PUT" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">PUT</option> <option value=\"DELETE\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">PUT</option> <option value=\"DELETE\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if explorer.Endpoint.Method == "DELETE" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">DELETE</option></select></div><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Request Body</h4><textarea id=\"request-body\" placeholder=\"Edit request body...\" class=\"w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">DELETE</option></select></div><div><label for=\"request-body\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Request Body</label> <textarea id=\"request-body\" placeholder=\"Edit request body\" class=\"input min-h-[150px] font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 274}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90\">Try It Out</button><div class=\"api-response mt-4 hidden\"><div class=\"flex justify-between mb-2 text-sm\"><span id=\"response-status\" class=\"font-semibold\"></span> <span id=\"response-time\" class=\"text-text-secondary\"></span></div><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code id=\"response-body\" class=\"text-text-primary text-sm\"></code></pre></div><div class=\"flex gap-2 mt-4 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Request</button> <button @click=\"copyDocResponse()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Response</button> <button @click=\"generateDocCURL()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Generate cURL</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("play", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Try It Out</button><div class=\"api-response hidden space-y-2\"><div class=\"flex justify-between text-sm\"><span id=\"response-status\" class=\"font-semibold\" style=\"color: var(--text-primary);\"></span> <span id=\"response-time\" style=\"color: var(--text-secondary);\"></span></div><pre class=\"card p-4 overflow-x-auto\"><code id=\"response-body\" class=\"text-sm\" style=\"color: var(--text-primary);\"></code></pre></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Copy Request</button> <button @click=\"copyDocResponse()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Copy Response</button> <button @click=\"generateDocCURL()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("external", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Generate cURL</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+264 -429
View File
@@ -19,567 +19,403 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) } data-conflict-id={ conflictID(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }>
<body
x-data="bookDetail"
class="theme-{ user.Theme }"
data-format-group={ book.FormatGroup }
data-library-id={ uuidToString(book.LibraryID) }
data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) }
data-conflict-id={ conflictID(book.ActiveConflict) }
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
>
@Header(user, "/media/{ uuidToString(book.ID) }")
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center gap-4">
<a id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Back
<!-- Hero with blurred cover backdrop -->
<div class="relative overflow-hidden">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div>
}
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
@Icon("arrow-left", "h-4 w-4")
<span>Back</span>
</a>
</div>
</div>
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 py-8">
<!-- Top Section: Cover + Basic Info + Actions -->
<div class="flex flex-col md:flex-row gap-8 mb-8">
<!-- Left: Cover Image (256x384px) -->
<div class="flex-shrink-0">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<img
src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-64 h-96 object-cover rounded-lg shadow-xl"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt="{ book.Title }"
class="w-64 h-96 object-cover rounded-lg shadow-xl"
/>
}
</div>
<!-- Right: Details -->
<div class="flex-1">
<!-- Title & Author -->
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
if book.Author.Valid && book.Author.String != "" {
<h2 class="text-2xl mb-6" style="color: var(--text-secondary)">
by { book.Author.String }
</h2>
}
<!-- Action Buttons -->
<div class="flex flex-wrap gap-3 mb-6">
<!-- Read Now (placeholder) -->
<button
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
class="px-6 py-3 rounded-lg font-semibold"
style="background-color: var(--accent); color: white;"
>
📖 Read Now
</button>
<!-- Mark as Read / Unread toggle -->
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
<button
@click="toggleRead(false)"
:disabled="readSaving"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
<span x-show="!readSaving"> Mark as Unread</span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
<div class="flex flex-col md:flex-row gap-8">
<!-- Cover -->
<div class="flex-shrink-0 mx-auto md:mx-0">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<img
src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
style="box-shadow: var(--shadow-pop);"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<button
@click="toggleRead(true)"
:disabled="readSaving"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
<span x-show="!readSaving">📖 Mark as Read</span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
<img
src="/static/placeholder-book.svg"
alt={ book.Title }
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
style="box-shadow: var(--shadow-pop);"
/>
}
<!-- Sync Progress -->
if book.ActiveConflict != nil || book.ReadingProgress != nil {
<button
@click="showProgressSyncModal()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
🔄 Sync Progress
</button>
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
if book.Author.Valid && book.Author.String != "" {
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
by { book.Author.String }
</h2>
}
<!-- View Notes/Highlights -->
<button
@click="showNotesModal()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
📝 Notes & Highlights
if book.NotesCount + book.HighlightsCount > 0 {
<span
class="ml-2 px-2 py-0.5 rounded text-xs font-semibold"
style="background-color: var(--accent); color: white;"
>
{ book.NotesCount + book.HighlightsCount }
</span>
<!-- Action buttons -->
<div class="flex flex-wrap gap-2.5 mb-6">
<button
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
class="btn btn-primary px-5 py-2.5"
>
@Icon("book-open", "h-4 w-4")
<span>Read Now</span>
</button>
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
<button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4")<span>Mark Unread</span></span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
} else {
<button @click="toggleRead(true)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check", "h-4 w-4")<span>Mark Read</span></span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
}
</button>
<!-- Edit Metadata (placeholder) -->
<button
@click="showMetadataEditor()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
✏️ Edit Metadata
</button>
</div>
<!-- Rating Display -->
<div class="mb-6" @mouseleave="ratingHover = 0">
<span class="text-2xl">
<template x-for="i in 5" :key="i">
<span style="position: relative; display: inline-block;">
<span :style="starFill(i)"></span>
<button
type="button"
@click="setRating(i*2-1)"
@mouseenter="ratingHover = i*2-1"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
<button
type="button"
@click="setRating(i*2)"
@mouseenter="ratingHover = i*2"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
</span>
</template>
</span>
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
<button
type="button"
x-show="userRating > 0 && !ratingSaving"
@click="clearRating()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
>Clear</button>
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<!-- Community Rating Display (from metadata) -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
<div class="mb-2">
<span class="text-lg" style="color: var(--text-secondary);">
Community Rating:
<span class="font-bold" style="color: var(--text-primary);">
if book.ActiveConflict != nil || book.ReadingProgress != nil {
<button @click="showProgressSyncModal()" class="btn btn-secondary px-5 py-2.5">
@Icon("sync", "h-4 w-4")
<span>Sync Progress</span>
</button>
}
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4")
<span>Notes</span>
if book.NotesCount + book.HighlightsCount > 0 {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
}
</button>
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4")
<span>Edit</span>
</button>
</div>
<!-- Rating -->
<div class="mb-5" @mouseleave="ratingHover = 0">
<span class="text-2xl">
<template x-for="i in 5" :key="i">
<span style="position: relative; display: inline-block;">
<span :style="starFill(i)"></span>
<button
type="button"
@click="setRating(i*2-1)"
@mouseenter="ratingHover = i*2-1"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
<button
type="button"
@click="setRating(i*2)"
@mouseenter="ratingHover = i*2"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
</span>
</template>
</span>
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
<button
type="button"
x-show="userRating > 0 && !ratingSaving"
@click="clearRating()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
>Clear</button>
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<!-- Community rating -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
<div class="mb-3 text-sm" style="color: var(--text-secondary);">
Community:
<span class="font-bold ml-1" style="color: var(--text-primary);">
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
</span>
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
</span>
</span>
</div>
}
<!-- Series Badge -->
if book.Series.Valid && book.Series.String != "" {
<div class="mb-4">
<a
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
class="px-3 py-1 rounded-full text-sm font-semibold inline-block hover:opacity-80 transition-opacity"
style="background-color: var(--accent); color: white; text-decoration: none;"
>
{ book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 }
}
</a>
</div>
}
<!-- Reading Direction Badge (for manga/comics) -->
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
<div class="mb-4">
<span
class="px-3 py-1 rounded-full text-sm font-semibold"
style="background-color: var(--accent); color: white;"
>
📖 { strings.ToUpper(book.ReadingDirection.String) }
</span>
</div>
}
<!-- Comic-Specific Badges -->
<div class="flex flex-wrap gap-2 mb-4">
<!-- Age Rating Badge -->
if book.AgeRating.Valid && book.AgeRating.String != "" {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
{ book.AgeRating.String }
</span>
<span class="ml-2">{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
</div>
}
<!-- Black and White Badge -->
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
B&W
</span>
}
<!-- Story Arc Badge -->
if book.StoryArc.Valid && book.StoryArc.String != "" {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
📚 { book.StoryArc.String }
</span>
}
</div>
<!-- Tags -->
if len(book.Tags) > 0 {
<div class="flex flex-wrap gap-2 mb-4">
for _, tag := range book.Tags {
<!-- Badges: series, direction, tags -->
<div class="flex flex-wrap items-center gap-2 mb-3">
if book.Series.Valid && book.Series.String != "" {
<a
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
class="px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
class="chip hover:opacity-90"
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
>
{ tag }
{ book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 }
}
</a>
}
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
}
if book.AgeRating.Valid && book.AgeRating.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span>
}
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span>
}
if book.StoryArc.Valid && book.StoryArc.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.StoryArc.String }</span>
}
</div>
}
<!-- Description/Synopsis -->
if book.Description.Valid && book.Description.String != "" {
<div class="mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
@UnsafeHTML(
bluemonday.UGCPolicy().Sanitize(book.Description.String),
).ToComponent()
if len(book.Tags) > 0 {
<div class="flex flex-wrap gap-2 mb-4">
for _, tag := range book.Tags {
<a
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
class="badge hover:opacity-80 transition-opacity"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
>
{ tag }
</a>
}
</div>
</div>
}
}
</div>
</div>
</div>
<!-- Summary (from ComicInfo.xml, if different from description) -->
</div>
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
<!-- Synopsis -->
if book.Description.Valid && book.Description.String != "" {
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
</div>
</div>
}
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
<div class="mb-6">
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3>
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
@UnsafeHTML(
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
).ToComponent()
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
</div>
</div>
}
<!-- Metadata Notes (technical notes from metadata files) -->
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3>
<div class="text-sm" style="color: var(--text-secondary);">
{ book.MetadataNotes.String }
</div>
<div class="text-sm" style="color: var(--text-secondary);">{ book.MetadataNotes.String }</div>
</div>
}
<!-- Progress Section -->
<!-- Progress -->
if book.ReadingProgress != nil {
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
if book.ActiveConflict != nil {
<div
class="mb-4 p-3 rounded-lg border"
style="background-color: #f59e0b20; border-color: #f59e0b;"
>
<p style="color: var(--text-primary);">
⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve
</p>
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border: 1px solid var(--status-warning);">
@Icon("alert", "h-5 w-5 shrink-0")
<p class="text-sm" style="color: var(--text-primary);">Progress conflict detected click "Sync Progress" to review and resolve.</p>
</div>
}
<!-- Progress Bar -->
<div class="mb-4">
<div class="w-full rounded-full h-3" style="background-color: var(--bg-primary);">
<div class="w-full rounded-full h-2.5" style="background-color: var(--bg-primary);">
<div
class="h-3 rounded-full transition-all"
class="h-2.5 rounded-full transition-all"
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
></div>
</div>
</div>
<!-- Progress Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p style="color: var(--text-secondary)">Progress</p>
<p class="text-2xl font-bold" style="color: var(--accent);">
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Progress</p>
<p class="text-2xl font-bold" style="color: var(--accent);">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
</div>
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
<div>
<p style="color: var(--text-secondary)">Page</p>
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Page</p>
<p style="color: var(--text-primary)">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
</div>
}
if book.ReadingProgress.LastReadAt.Valid {
<div>
<p style="color: var(--text-secondary)">Last Read</p>
<p>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Last Read</p>
<p class="text-sm" style="color: var(--text-primary)">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
</div>
}
if book.ReadingProgress.LastSyncSource.Valid {
<div>
<p style="color: var(--text-secondary)">Source</p>
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Source</p>
<p class="capitalize" style="color: var(--text-primary)">{ book.ReadingProgress.LastSyncSource.String }</p>
</div>
}
</div>
</div>
}
<!-- Metadata Grid -->
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<!-- Metadata grid -->
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Publication Info -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4 text-sm">
if book.Publisher.Valid && book.Publisher.String != "" {
<div>
<p style="color: var(--text-secondary)">Publisher</p>
<p>{ book.Publisher.String }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
<p style="color: var(--text-primary)">{ book.Publisher.String }</p>
</div>
}
if book.DatePublished.Valid {
<div>
<p style="color: var(--text-secondary)">Published</p>
<p>{ book.DatePublished.Time.Format("01-02-2006") }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Published</p>
<p style="color: var(--text-primary)">{ book.DatePublished.Time.Format("01-02-2006") }</p>
</div>
}
if book.Isbn.Valid && book.Isbn.String != "" {
<div>
<p style="color: var(--text-secondary)">Isbn</p>
<p>{ book.Isbn.String }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">ISBN</p>
<p style="color: var(--text-primary)">{ book.Isbn.String }</p>
</div>
}
if book.Language.Valid && book.Language.String != "" {
<div>
<p style="color: var(--text-secondary)">Language</p>
<p class="capitalize">{ book.Language.String }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
<p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
</div>
}
if book.Edition.Valid && book.Edition.String != "" {
<div>
<p style="color: var(--text-secondary)">Edition</p>
<p>{ book.Edition.String }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
<p style="color: var(--text-primary)">{ book.Edition.String }</p>
</div>
}
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Pages</p>
<p>{ book.PageCount.Int32 }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
<p style="color: var(--text-primary)">{ book.PageCount.Int32 }</p>
</div>
}
if book.Genre.Valid && book.Genre.String != "" {
<div>
<p style="color: var(--text-secondary)">Genre</p>
<p>{ book.Genre.String }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
<p style="color: var(--text-primary)">{ book.Genre.String }</p>
</div>
}
<!-- Series Information (if not shown in badge) -->
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Series Count</p>
<p>{ book.SeriesCount.Int32 } items</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Series Count</p>
<p style="color: var(--text-primary)">{ book.SeriesCount.Int32 } items</p>
</div>
}
if book.Volume.Valid && book.Volume.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Volume</p>
<p>Vol. { book.Volume.Int32 }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Volume</p>
<p style="color: var(--text-primary)">Vol. { book.Volume.Int32 }</p>
</div>
}
if book.Imprint.Valid && book.Imprint.String != "" {
<div>
<p style="color: var(--text-secondary)">Imprint</p>
<p>{ book.Imprint.String }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
<p style="color: var(--text-primary)">{ book.Imprint.String }</p>
</div>
}
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Copyright Year</p>
<p>{ book.CopyrightYear.Int32 }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Copyright</p>
<p style="color: var(--text-primary)">{ book.CopyrightYear.Int32 }</p>
</div>
}
<!-- Comic-Specific Fields -->
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
<div>
<p style="color: var(--text-secondary)">Manga Type</p>
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Manga Type</p>
<p class="capitalize" style="color: var(--text-primary)">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
</div>
}
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
<div>
<p style="color: var(--text-secondary)">Scan Info</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Scan Info</p>
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
</div>
}
if len(book.AlternateInfo) > 0 {
<div>
<p style="color: var(--text-secondary)">Alternate Series</p>
<p class="text-sm">{ string(book.AlternateInfo) }</p>
</div>
}
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
<div>
<p style="color: var(--text-secondary)">Alternate Series</p>
<p>{ altSeries }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Alternate Series</p>
<p style="color: var(--text-primary)">{ altSeries }</p>
</div>
}
if len(book.Contributors) > 0 {
<div>
<p style="color: var(--text-secondary)">Contributors</p>
<p>{ strings.Join(book.Contributors, ", ") }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Contributors</p>
<p style="color: var(--text-primary)">{ strings.Join(book.Contributors, ", ") }</p>
</div>
}
<!-- Technical Info -->
<div>
<p style="color: var(--text-secondary)">Format</p>
<p>{ book.MimeType.String }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
</div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div>
<p style="color: var(--text-secondary)">File Size</p>
<p>{ formatFileSize(book.FileSize.Int64) }</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
</div>
}
</div>
<!-- External IDs Section -->
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<div class="mt-4 pt-4 border-t" style="border-color: var(--border);">
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
<div class="flex flex-wrap gap-3">
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
<a
href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
📚 Goodreads
</a>
} else {
<a
href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
📚 Goodreads
</a>
}
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
<a
href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
📖 Open Library
</a>
} else {
<a
href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
📖 Open Library
</a>
}
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
<a
href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🔍 Google Books
</a>
} else {
<a
href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🔍 Google Books
</a>
}
if book.Asin.Valid && book.Asin.String != "" {
<a
href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🛒 Amazon
</a>
} else if book.Isbn.Valid && book.Isbn.String != "" {
<a
href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🛒 Amazon
</a>
}
if book.WebUrl.Valid && book.WebUrl.String != "" {
<a
href={ book.WebUrl.String }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🔗 { getDomainName(book.WebUrl.String) }
</a>
}
</div>
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
<a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
} else {
<a href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
}
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
<a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
} else {
<a href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
}
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
<a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
} else {
<a href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
}
if book.Asin.Valid && book.Asin.String != "" {
<a href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
} else if book.Isbn.Valid && book.Isbn.String != "" {
<a href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
}
if book.WebUrl.Valid && book.WebUrl.String != "" {
<a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">{ getDomainName(book.WebUrl.String) }</a>
}
</div>
}
</div>
<!-- Collections Section -->
<!-- Collections -->
if len(book.Collections) > 0 {
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-3">
<div class="flex flex-wrap gap-2.5">
for _, col := range book.Collections {
<a
href={ "/collections/" + uuidToString(col.ID) }
class="px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity"
style="border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;"
class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border hover:opacity-90 transition-opacity"
style={ "border-color: " + col.Color.String + "; background-color: var(--bg-primary); text-decoration: none;" }
>
<span style="color: { col.Color.String };">{ col.Icon.String }</span>
<span style={ "color: " + col.Color.String }>{ col.Icon.String }</span>
<span style="color: var(--text-primary);">{ col.Name }</span>
</a>
}
@@ -587,7 +423,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</div>
}
</div>
<!-- Modals -->
@ProgressSyncModal(user, book)
@NotesHighlightsModal(book)
@MetadataEditorModal(book)
+182 -230
View File
@@ -8,50 +8,52 @@ import (
templ ProgressSyncModal(user User, book handlers.MediaDetail) {
<div
id="progress-sync-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
style="background-color: rgba(0, 0, 0, 0.7);"
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 rounded-lg p-6 w-full max-w-4xl mx-4 my-8"
style="background-color: var(--bg-secondary); border-color: var(--border);"
class="card w-full max-w-4xl my-8 p-6"
style="box-shadow: var(--shadow-pop);"
>
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("sync", "h-5 w-5")
</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
</div>
</div>
<button
@click="hideProgressSyncModal()"
class="p-2 hover:opacity-80 rounded-lg"
style="color: var(--text-primary); background-color: var(--bg-primary);"
class="icon-btn"
aria-label="Close"
>
@Icon("close", "h-5 w-5")
</button>
</div>
if book.ActiveConflict != nil {
<div
class="mb-6 p-4 rounded-lg border"
style="background-color: #f59e0b20; border-color: #f59e0b;"
class="mb-6 p-4 rounded-xl flex items-start gap-3"
style="background-color: color-mix(in srgb, var(--status-warning) 15%, transparent); border: 1px solid var(--status-warning);"
>
<p class="font-semibold mb-2" style="color: var(--text-primary);">
⚠️ Conflict Detected
</p>
<p class="text-sm" style="color: var(--text-secondary);">
Progress differs between devices. Choose which version to keep.
</p>
<span class="shrink-0 mt-0.5" style="color: var(--status-warning);">@Icon("alert", "h-5 w-5")</span>
<div>
<p class="font-semibold mb-1" style="color: var(--text-primary);">
Conflict Detected
</p>
<p class="text-sm" style="color: var(--text-secondary);">
Progress differs between devices. Choose which version to keep.
</p>
</div>
</div>
<div class="space-y-4">
for source, data := range book.ActiveConflict.ConflictData {
<div
class="card p-4 rounded-lg border"
style="background-color: var(--bg-primary); border-color: var(--border);"
>
<div class="card p-4">
<div class="flex justify-between items-start mb-3">
<div>
<span
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
style="background-color: var(--accent); color: white;"
>
<span class="badge mb-2" style="background-color: var(--accent); color: #fff;">
{ data.Source }
</span>
<p class="text-xs" style="color: var(--text-secondary);">
@@ -83,8 +85,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
<div class="mt-3">
<button
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
class="px-3 py-1.5 rounded-lg text-sm font-semibold"
style="background-color: var(--accent); color: white;"
class="btn btn-primary"
>
Keep This
</button>
@@ -96,10 +97,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
<div class="mb-4" style="color: var(--text-secondary);">
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
</div>
<div
class="card p-6 rounded-lg border text-center"
style="background-color: var(--bg-primary); border-color: var(--border);"
>
<div class="card p-6 text-center">
<div class="text-5xl font-bold mb-4" style="color: var(--accent);">
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
</div>
@@ -121,8 +119,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
<div class="flex justify-end space-x-3 mt-6">
<button
@click="hideProgressSyncModal()"
class="px-4 py-2 rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
class="btn btn-secondary"
>
Close
</button>
@@ -134,14 +131,16 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
templ NotesHighlightsModal(book handlers.MediaDetail) {
<div
id="notes-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);"
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div
class="card rounded-lg p-8 w-full max-w-2xl mx-4 text-center"
style="background-color: var(--bg-secondary); border-color: var(--border);"
class="card w-full max-w-2xl p-8 text-center"
style="box-shadow: var(--shadow-pop);"
>
<div class="text-6xl mb-4">📝</div>
<span class="inline-grid place-items-center h-14 w-14 rounded-2xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("edit", "h-7 w-7")
</span>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2>
<p class="mb-2" style="color: var(--text-secondary);">
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
@@ -150,8 +149,7 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
<div>
<button
@click="hideNotesModal()"
class="px-6 py-2 rounded-lg font-semibold"
style="background-color: var(--accent); color: white;"
class="btn btn-primary"
>
Close
</button>
@@ -163,27 +161,31 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
templ MetadataEditorModal(book handlers.MediaDetail) {
<div
id="metadata-editor-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
style="background-color: rgba(0, 0, 0, 0.7);"
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="rounded-lg w-full max-w-5xl mx-4 my-8 flex flex-col max-h-[90vh]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
style="box-shadow: var(--shadow-pop);"
>
<div class="flex justify-between items-center p-6 border-b flex-shrink-0" style="border-color: var(--border);">
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Edit Metadata</h2>
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("edit", "h-5 w-5")
</span>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Metadata</h2>
</div>
<button
@click="hideMetadataEditor()"
class="p-2 hover:opacity-80 rounded-lg"
style="color: var(--text-primary); background-color: var(--bg-primary);"
class="icon-btn"
aria-label="Close"
>
@Icon("close", "h-5 w-5")
</button>
</div>
<div class="flex flex-col md:flex-row gap-8 p-6 flex-1 min-h-0 overflow-y-auto">
<!-- Cover Section (Left) -->
<div class="flex-shrink-0">
<div class="w-64 h-96 rounded-lg overflow-hidden shadow-lg mb-3 cursor-pointer relative group"
<div class="w-64 h-96 rounded-xl overflow-hidden mb-3 cursor-pointer relative group"
style="background-color: var(--bg-primary);"
@click="document.getElementById('cover-upload-input').click()"
>
@@ -209,27 +211,26 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<div class="w-64 space-y-2">
<button
type="button"
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
style="background-color: var(--accent); color: white;"
class="btn btn-primary w-full"
@click="generateCover()"
x-show="coverGenerating"
disabled
>
@Icon("refresh", "h-4 w-4")
Generating...
</button>
<button
type="button"
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
style="background-color: var(--accent); color: white;"
class="btn btn-primary w-full"
@click="generateCover()"
x-show="!coverGenerating"
>
@Icon("refresh", "h-4 w-4")
Generate Cover
</button>
<button
type="button"
class="w-full px-3 py-2 text-sm rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
class="btn btn-ghost w-full"
@click="removeCover()"
x-show="hasExistingCover || newCoverPreview"
>
@@ -237,53 +238,43 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</button>
</div>
</div>
<!-- Accordion Fields (Right) -->
<div class="flex-1 min-w-0 space-y-2">
<!-- Basic Info -->
<div class="border rounded-lg" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
style="color: var(--text-primary); background-color: var(--bg-primary);"
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
style="color: var(--text-primary);"
@click="toggleSection('basic')">
<span class="font-semibold">Basic Info</span>
<span x-text="openSections.basic ? '▾' : '▸'"></span>
<span class="inline-flex">
<span x-show="openSections.basic">@Icon("chevron-down", "h-4 w-4")</span>
<span x-show="!openSections.basic" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
</span>
</button>
<div x-show="openSections.basic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
<div x-show="openSections.basic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Title</label>
<input type="text" name="title" value={ book.Title }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label>
<input type="text" name="title" value={ book.Title } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Author</label>
<input type="text" name="author" value={ textToString(book.Author) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label>
<input type="text" name="author" value={ textToString(book.Author) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Description</label>
<textarea name="description" rows="3"
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
<textarea name="description" rows="3" class="input"
>{ textToString(book.Description) }</textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Summary</label>
<textarea name="summary" rows="2"
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Summary</label>
<textarea name="summary" rows="2" class="input"
>{ textToString(book.Summary) }</textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Tags</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Tags</label>
<div class="flex flex-wrap gap-1.5 mb-2">
<template x-for="(tag, idx) in editorTags" :key="idx">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium"
style="background-color: var(--accent); color: white;"
>
<span class="chip">
<span x-text="tag"></span>
<button type="button" class="hover:opacity-70 leading-none" @click="removeEditorTag(idx)"></button>
<button type="button" class="hover:bg-surface-hover rounded leading-none" @click="removeEditorTag(idx)">@Icon("close", "h-3 w-3")</button>
</span>
</template>
</div>
@@ -297,20 +288,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
@keydown="onTagKeydown($event)"
@blur="hideEditorTagDropdown()"
placeholder="Add tag..."
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input" />
<div
x-show="showTagDropdown"
x-transition
class="mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
style="background-color: var(--bg-primary); border-color: var(--border);"
x-cloak
class="mt-1 w-full rounded-lg border max-h-48 overflow-y-auto"
style="background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);"
>
<template x-for="sug in tagSuggestions" :key="sug.value">
<button
type="button"
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
:style="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover"
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''"
@click="selectEditorTagSuggestion(sug.value)"
>
<span x-text="sug.value"></span>
@@ -321,164 +311,142 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label>
<input type="number" name="community_rating" min="0" max="10" step="0.1"
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input" />
</div>
</div>
</div>
<!-- Publication -->
<div class="border rounded-lg" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
style="color: var(--text-primary); background-color: var(--bg-primary);"
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
style="color: var(--text-primary);"
@click="toggleSection('publication')">
<span class="font-semibold">Publication</span>
<span x-text="openSections.publication ? '▾' : '▸'"></span>
<span class="inline-flex">
<span x-show="openSections.publication">@Icon("chevron-down", "h-4 w-4")</span>
<span x-show="!openSections.publication" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
</span>
</button>
<div x-show="openSections.publication" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Publisher</label>
<input type="text" name="publisher" value={ textToString(book.Publisher) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label>
<input type="text" name="publisher" value={ textToString(book.Publisher) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Date Published</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label>
<input type="date" name="date_published"
value={ formatDateForInput(book.DatePublished) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Edition</label>
<input type="text" name="edition" value={ textToString(book.Edition) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Edition</label>
<input type="text" name="edition" value={ textToString(book.Edition) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Language</label>
<input type="text" name="language" value={ textToString(book.Language) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Language</label>
<input type="text" name="language" value={ textToString(book.Language) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Genre</label>
<input type="text" name="genre" value={ textToString(book.Genre) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Genre</label>
<input type="text" name="genre" value={ textToString(book.Genre) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Copyright Year</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Copyright Year</label>
<input type="number" name="copyright_year"
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input" />
</div>
</div>
</div>
<!-- Series -->
<div class="border rounded-lg" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
style="color: var(--text-primary); background-color: var(--bg-primary);"
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
style="color: var(--text-primary);"
@click="toggleSection('series')">
<span class="font-semibold">Series</span>
<span x-text="openSections.series ? '▾' : '▸'"></span>
<span class="inline-flex">
<span x-show="openSections.series">@Icon("chevron-down", "h-4 w-4")</span>
<span x-show="!openSections.series" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
</span>
</button>
<div x-show="openSections.series" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
<div x-show="openSections.series" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Series</label>
<input type="text" name="series" value={ textToString(book.Series) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Series</label>
<input type="text" name="series" value={ textToString(book.Series) } class="input" />
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Number</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
<input type="number" name="series_number"
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Count</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Count</label>
<input type="number" name="series_count"
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Volume</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Volume</label>
<input type="number" name="volume"
value={ fmt.Sprintf("%d", book.Volume.Int32) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input" />
</div>
</div>
</div>
</div>
<!-- Identifiers -->
<div class="border rounded-lg" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
style="color: var(--text-primary); background-color: var(--bg-primary);"
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
style="color: var(--text-primary);"
@click="toggleSection('identifiers')">
<span class="font-semibold">Identifiers</span>
<span x-text="openSections.identifiers ? '▾' : '▸'"></span>
<span class="inline-flex">
<span x-show="openSections.identifiers">@Icon("chevron-down", "h-4 w-4")</span>
<span x-show="!openSections.identifiers" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
</span>
</button>
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">ISBN</label>
<input type="text" name="isbn" value={ textToString(book.Isbn) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ISBN</label>
<input type="text" name="isbn" value={ textToString(book.Isbn) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">ASIN</label>
<input type="text" name="asin" value={ textToString(book.Asin) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ASIN</label>
<input type="text" name="asin" value={ textToString(book.Asin) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Goodreads ID</label>
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Goodreads ID</label>
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label>
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label>
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Google Books ID</label>
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Google Books ID</label>
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Web URL</label>
<input type="url" name="web_url" value={ textToString(book.WebUrl) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Web URL</label>
<input type="url" name="web_url" value={ textToString(book.WebUrl) } class="input" />
</div>
</div>
</div>
<!-- Comic/Manga -->
<div class="border rounded-lg" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
style="color: var(--text-primary); background-color: var(--bg-primary);"
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
style="color: var(--text-primary);"
@click="toggleSection('comic')">
<span class="font-semibold">Comic/Manga</span>
<span x-text="openSections.comic ? '▾' : '▸'"></span>
<span class="inline-flex">
<span x-show="openSections.comic">@Icon("chevron-down", "h-4 w-4")</span>
<span x-show="!openSections.comic" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
</span>
</button>
<div x-show="openSections.comic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
<div x-show="openSections.comic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
<div>
<label for="manga_type" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label>
<select id="manga_type" name="manga_type"
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
<label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Manga Type</label>
<select id="manga_type" name="manga_type" class="input">
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
@@ -486,10 +454,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</select>
</div>
<div>
<label for="reading_direction" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label>
<select id="reading_direction" name="reading_direction"
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
<label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Reading Direction</label>
<select id="reading_direction" name="reading_direction" class="input">
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
@@ -497,34 +463,24 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Age Rating</label>
<input type="text" name="age_rating" value={ textToString(book.AgeRating) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Age Rating</label>
<input type="text" name="age_rating" value={ textToString(book.AgeRating) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Story Arc</label>
<input type="text" name="story_arc" value={ textToString(book.StoryArc) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Story Arc</label>
<input type="text" name="story_arc" value={ textToString(book.StoryArc) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Imprint</label>
<input type="text" name="imprint" value={ textToString(book.Imprint) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Imprint</label>
<input type="text" name="imprint" value={ textToString(book.Imprint) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Scan Information</label>
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Scan Information</label>
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) } class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
<textarea name="metadata_notes" rows="2"
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
<textarea name="metadata_notes" rows="2" class="input"
>{ textToString(book.MetadataNotes) }</textarea>
</div>
<div class="flex items-center gap-2">
@@ -535,40 +491,37 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div>
</div>
</div>
<!-- Technical -->
<div class="border rounded-lg" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
style="color: var(--text-primary); background-color: var(--bg-primary);"
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
style="color: var(--text-primary);"
@click="toggleSection('technical')">
<span class="font-semibold">Technical</span>
<span x-text="openSections.technical ? '▾' : '▸'"></span>
<span class="inline-flex">
<span x-show="openSections.technical">@Icon("chevron-down", "h-4 w-4")</span>
<span x-show="!openSections.technical" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
</span>
</button>
<div x-show="openSections.technical" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
<div x-show="openSections.technical" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Page Count</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Page Count</label>
<input type="number" name="page_count"
value={ fmt.Sprintf("%d", book.PageCount.Int32) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) }
class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Format</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label>
<input type="text" value={ textToString(book.MimeType) } readonly
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input opacity-60" />
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">File Size</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label>
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
class="input opacity-60" />
</div>
</div>
</div>
@@ -578,16 +531,15 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<button
@click="hideMetadataEditor()"
class="px-4 py-2 rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
class="btn btn-secondary"
>
Cancel
</button>
<button
@click="saveMetadata()"
class="px-6 py-2 rounded-lg font-semibold"
style="background-color: var(--accent); color: white;"
class="btn btn-primary"
>
@Icon("save", "h-4 w-4")
Save
</button>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+250 -348
View File
@@ -3,6 +3,7 @@ package templates
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"fmt"
)
templ BookShelf(
@@ -11,10 +12,10 @@ templ BookShelf(
currentLibraryID string,
errorMessage string,
savedFilters []database.SavedFilters,
books []handlers.BookInfo, // NEW
limit int, // NEW
offset int, // NEW
count int, // NEW
books []handlers.BookInfo,
limit int,
offset int,
count int,
) {
<!DOCTYPE html>
<html lang="en">
@@ -31,363 +32,287 @@ templ BookShelf(
x-init="initBookshelf()"
>
@Header(user, "/bookshelf")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<!-- Filter Bar -->
<div
class="mb-6 card p-4 rounded-lg border"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
<div class="flex flex-wrap gap-4 items-center">
<!-- Library Selector -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Library
</label>
<select
id="library-select"
name="library_id"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
>
if len(libraries) == 0 {
<option value="">No libraries available</option>
} else {
if currentLibraryID == "" {
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
} else {
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
}
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
}
<div class="w-full px-4 sm:px-6 lg:px-8 py-6">
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<div class="relative flex-1 min-w-[200px]">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
@Icon("search", "h-4 w-4")
</span>
<input
name="q"
type="text"
class="input pl-10"
placeholder="Search all fields…"
/>
</div>
<select
name="sort"
class="input w-auto cursor-pointer"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="change"
hx-include="#filter-form"
>
<option value="title ASC">Title (A-Z)</option>
<option value="title DESC">Title (Z-A)</option>
<option value="author ASC">Author (A-Z)</option>
<option value="created_at DESC">Date Added</option>
<option value="page_count DESC">Page Count</option>
</select>
<button type="button" @click="filtersOpen = true" class="btn btn-secondary">
@Icon("filter", "h-4 w-4")
<span>Filters</span>
</button>
<button type="button" @click="showSaveFilterModal()" class="btn btn-secondary">
@Icon("save", "h-4 w-4")
<span>Save</span>
</button>
<div class="relative">
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-secondary">
@Icon("folder", "h-4 w-4")
<span>Load</span>
</button>
<div
x-show="showFiltersDropdown"
@click.outside="showFiltersDropdown = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl shadow-lg z-50 card p-4"
x-cloak
>
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Saved Filters</h3>
<div class="space-y-2" id="saved-filters-list">
for _, filter := range savedFilters {
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded text-sm" style="color: var(--text-primary);">
{ filter.Name }
</button>
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-8 w-8" title="Delete filter">
@Icon("trash", "h-4 w-4")
</button>
</div>
}
</div>
if len(savedFilters) == 0 {
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
No saved filters yet
</div>
}
</select>
</div>
<!-- Search Input -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Search
</label>
<input
name="q"
type="text"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search all fields..."
/>
</div>
<button type="button" @click="clearFilters()" class="btn btn-ghost">
@Icon("close", "h-4 w-4")
<span>Clear</span>
</button>
</div>
<!-- Filter drawer -->
<div
x-show="filtersOpen"
x-cloak
class="fixed inset-0 z-[65]"
@keydown.escape.window="filtersOpen = false"
>
<div class="absolute inset-0" style="background-color: var(--surface-overlay);" @click="filtersOpen = false"></div>
<div
class="absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto"
style="background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
>
<div class="sticky top-0 flex items-center justify-between px-6 py-4 border-b" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Filters</h2>
<button type="button" @click="filtersOpen = false" class="icon-btn" aria-label="Close filters">
@Icon("close", "h-5 w-5")
</button>
</div>
<!-- Author Filter with Autocomplete -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Author
</label>
<input
type="text"
name="author_filter"
placeholder="Filter by author"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
/>
<datalist id="author-datalist"></datalist>
</div>
<!-- Tags Filter with Autocomplete -->
<div class="flex-1 min-w-[150px] relative">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
<input
type="text"
name="tags_filter"
placeholder="Filter by tags"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
@keydown="onTagFilterKeydown($event)"
@blur="hideTagDropdown()"
/>
<div
x-show="showTagDropdown"
x-transition
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
style="background-color: var(--bg-primary); border-color: var(--border);"
>
<template x-for="sug in tagSuggestions" :key="sug.value">
<button
type="button"
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
@click="selectTagSuggestion(sug.value)"
>
<span x-text="sug.value"></span>
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
</button>
<div class="p-6 space-y-5">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library</label>
<select id="library-select" name="library_id" class="input">
if len(libraries) == 0 {
<option value="">No libraries available</option>
} else {
if currentLibraryID == "" {
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
} else {
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
}
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
}
}
}
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label>
<input
type="text"
name="author_filter"
placeholder="Filter by author"
class="input"
list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
/>
<datalist id="author-datalist"></datalist>
</div>
<div class="relative">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label>
<input
type="text"
name="tags_filter"
placeholder="Filter by tags"
class="input"
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
@keydown="onTagFilterKeydown($event)"
@blur="hideTagDropdown()"
/>
<div
x-show="showTagDropdown"
x-transition
x-cloak
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto"
style="background-color: var(--bg-primary); border: 1px solid var(--border);"
>
<template x-for="sug in tagSuggestions" :key="sug.value">
<button
type="button"
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
@click="selectTagSuggestion(sug.value)"
>
<span x-text="sug.value"></span>
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
</button>
</template>
</div>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Series</label>
<input
type="text"
name="series_filter"
placeholder="Filter by series"
class="input"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/>
<datalist id="series-datalist"></datalist>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label>
<input
type="text"
name="language_filter"
placeholder="Filter by language"
class="input"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/>
<datalist id="language-datalist"></datalist>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Year Range</label>
<div class="flex gap-2">
<input type="number" name="year_min" placeholder="From" class="input"/>
<input type="number" name="year_max" placeholder="To" class="input"/>
</div>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Cover</label>
<input
type="button"
id="has-cover-tristate"
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
:class="{
'state-null': hasCoverState === null,
'state-true': hasCoverState === true,
'state-false': hasCoverState === false
}"
:value="hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
@click="cycleHasCover()"
/>
<template x-if="hasCoverState !== null">
<input
type="hidden"
name="has_cover"
:value="hasCoverState ? 'true' : 'false'"
/>
</template>
</div>
</div>
// <!-- Genre Filter with Autocomplete -->
// <div class="flex-1 min-w-[150px]">
// <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
// Genre
// </label>
// <input
// type="text"
// name="genre_filter"
// placeholder="Filter by genre"
// class="w-full px-3 py-2 border rounded-lg"
// style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
// list="genre-datalist"
// @input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
// />
// <datalist id="genre-datalist"></datalist>
// </div>
<!-- Series Filter with Autocomplete (NEW) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Series
</label>
<input
type="text"
name="series_filter"
placeholder="Filter by series"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/>
<datalist id="series-datalist"></datalist>
</div>
<!-- Language Filter with Autocomplete (NEW) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Language
</label>
<input
type="text"
name="language_filter"
placeholder="Filter by language"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/>
<datalist id="language-datalist"></datalist>
</div>
<!-- Year Range -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Year Range
</label>
<div class="flex gap-2">
<input
type="number"
name="year_min"
placeholder="From"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
<input
type="number"
name="year_max"
placeholder="To"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
</div>
</div>
<!-- Has Cover Filter - TriState Button -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Cover Filter
</label>
<input
type="button"
id="has-cover-tristate"
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
:class="{
'state-null': hasCoverState === null,
'state-true': hasCoverState === true,
'state-false': hasCoverState === false
}"
:value="hasCoverState === null ? '○ Cover: Any' : hasCoverState === true ? '✓ Has Cover' : '✗ No Cover'"
@click="cycleHasCover()"
/>
<!-- Hidden input for HTMX form submission - conditionally rendered -->
<template x-if="hasCoverState !== null">
<input
type="hidden"
name="has_cover"
:value="hasCoverState ? 'true' : 'false'"
/>
</template>
</div>
<!-- Sort By Dropdown (PRESERVED) -->
<div class="flex-1 min-w-[150px]">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Sort By
</label>
<select
name="sort"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="change"
hx-include="#filter-form"
>
<option value="title ASC">Title (A-Z)</option>
<option value="title DESC">Title (Z-A)</option>
<option value="author ASC">Author (A-Z)</option>
<option value="created_at DESC">Date Added</option>
<option value="page_count DESC">Page Count</option>
</select>
</div>
<!-- Submit Button -->
<div class="flex items-end">
<button
type="submit"
class="px-6 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
>
🔍 Search
</button>
</div>
<!-- Save Filter Button (PRESERVED) -->
<div class="flex items-end">
<button
@click="showSaveFilterModal()"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
>
💾 Save Filter
</button>
</div>
<!-- Load Filter Button (PRESERVED) -->
<div class="flex items-end relative">
<button
@click="toggleFiltersDropdown()"
data-load-filter-btn
class="px-4 py-2 rounded-lg font-medium border"
style="border-color: var(--border); color: var(--text-primary);"
>
📂 Load Filter
</button>
<!-- Saved Filters Dropdown (PRESERVED) -->
<div
x-show="showFiltersDropdown"
@click.outside="showFiltersDropdown = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute top-full mt-2 w-80 rounded-lg shadow-lg z-50"
:class="dropdownAlign === 'left' ? 'left-0' : 'right-0'"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
>
<div class="p-4">
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">
Saved Filters
</h3>
<!-- Filter List -->
<div class="space-y-2" id="saved-filters-list">
for _, filter := range savedFilters {
<div class="flex items-center justify-between p-2 rounded hover:opacity-80" style="background-color: var(--bg-primary);" data-filter-id={ uuidToString(filter.ID) }>
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);">
{ filter.Name }
</button>
<button data-action="delete-filter" @click="deleteFilter($event)" class="p-1 hover:opacity-70 rounded" style="color: var(--text-secondary);" title="Delete filter">🗑️</button>
</div>
}
</div>
<!-- Empty State -->
if len(savedFilters) == 0 {
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
No saved filters yet
</div>
}
</div>
</div>
</div>
<!-- Clear Filters Button (PRESERVED) -->
<div class="flex items-end">
<button
@click="clearFilters()"
class="px-4 py-2 rounded-lg font-medium border"
style="border-color: var(--border); color: var(--text-primary);"
>
Clear
</button>
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<button type="submit" @click="filtersOpen = false" class="btn btn-primary w-full">Apply Filters</button>
</div>
</div>
</form>
</div>
<!-- Books Grid -->
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
</div>
</form>
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
@BooksGrid(books, limit, offset, count, currentLibraryID)
<!-- Error Message -->
if errorMessage != "" {
<div class="mt-6 p-4 rounded-lg border bg-red-500/10 border-red-500">
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
<p style="color: var(--text-primary)">{ errorMessage }</p>
</div>
}
</div>
<!-- Pagination -->
<div id="pagination" class="mt-6 flex justify-center gap-2">
<div id="pagination" class="mt-6 flex justify-center items-center gap-2">
if count > 0 {
<button
type="button"
class="px-4 py-2 rounded-lg border disabled:opacity-50"
style="border-color: var(--border); color: var(--text-primary);"
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
class="btn btn-secondary disabled:opacity-40"
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit) }
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset <= 0 }
>
Previous
@Icon("chevron-left", "h-4 w-4")
<span>Prev</span>
</button>
<span class="px-4 py-2" style="color: var(--text-secondary);">
<span class="px-4 py-2 text-sm" style="color: var(--text-secondary);">
Page { offset / limit + 1 }
</span>
<button
class="px-4 py-2 rounded-lg border disabled:opacity-50"
style="border-color: var(--border); color: var(--text-primary);"
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }"
type="button"
class="btn btn-secondary disabled:opacity-40"
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit) }
hx-target="#books-grid"
hx-include="#filter-form"
disabled?={ offset+limit >= count }
>
Next
<span>Next</span>
@Icon("chevron-right", "h-4 w-4")
</button>
}
</div>
</div>
<!-- Save Filter Modal -->
<div
x-show="showSaveModal"
@click.self="showSaveModal = false"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
x-cloak
class="fixed inset-0 z-[70] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div
@click.stop
class="card rounded-lg p-6 w-full max-w-md mx-4"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div @click.stop class="card rounded-2xl p-6 w-full max-w-md">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
<button
@click="showSaveModal = false"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-primary)"
></button>
<button @click="showSaveModal = false" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<form
hx-post="/api/saved-filters"
@@ -397,38 +322,15 @@ templ BookShelf(
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Filter Name
</label>
<input
type="text"
name="filter_name"
placeholder="My Custom Filter"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
required
/>
<div id="filter-save-error" style="display: none; color: #ef4444; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;"></div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label>
<input type="text" name="filter_name" placeholder="My Custom Filter" class="input" required/>
<div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></div>
<input type="hidden" name="resource_type" value="media-items"/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
@click="showSaveModal = false"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
>
Save
</button>
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
<!-- Hidden pagination state -->
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form>
File diff suppressed because one or more lines are too long
+13 -12
View File
@@ -16,20 +16,21 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
</head>
<body class="theme-{ user.Theme }">
@Header(user, backUrl)
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center gap-4">
<a href={ backUrl } class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
{ backLabel }
<div class="app-subbar border-b" style="background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-3">
<a href={ backUrl } class="btn btn-ghost">
@Icon("arrow-left", "h-4 w-4")
<span>{ backLabel }</span>
</a>
</div>
</div>
<div class="w-full px-4 py-8">
<div class="flex items-center gap-4 mb-6">
<span class="px-3 py-1 rounded-full text-sm font-semibold" style="background-color: var(--accent); color: white;">
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-wrap items-center gap-3 mb-6">
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">
{ badgeIcon } { badgeLabel }
</span>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ title }</h1>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d", len(books)) } books</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ title }</h1>
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ fmt.Sprintf("%d", len(books)) } books</span>
</div>
if len(books) > 0 {
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
@@ -38,10 +39,10 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
}
</div>
} else {
<div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">{ emptyIcon }</div>
<div class="card text-center py-16 px-6">
<div class="text-5xl mb-4">{ emptyIcon }</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Found</h3>
<p>{ emptyMessage }</p>
<p style="color: var(--text-secondary)">{ emptyMessage }</p>
</div>
}
</div>
+29 -21
View File
@@ -55,7 +55,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center gap-4\"><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"app-subbar border-b\" style=\"background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-3\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -68,77 +68,85 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"text-sm hover:opacity-80 transition-opacity\" style=\"color: var(--text-secondary); text-decoration: none;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"btn btn-ghost\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 23, Col: 23}
}
_, 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, 5, "</a></div></div><div class=\"w-full px-4 py-8\"><div class=\"flex items-center gap-4 mb-6\"><span class=\"px-3 py-1 rounded-full text-sm font-semibold\" style=\"background-color: var(--accent); color: white;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></a></div></div><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"flex flex-wrap items-center gap-3 mb-6\"><span class=\"chip\" style=\"background-color: var(--accent); color: var(--bg-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, Col: 17}
}
_, 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, 6, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, Col: 32}
}
_, 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, 7, "</span><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 32, Col: 93}
}
_, 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, 8, "</h1><span class=\"text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h1><span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 32, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 125}
}
_, 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, 9, " books</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " books</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(books) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -148,43 +156,43 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card text-center py-16 px-6\"><div class=\"text-5xl mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 42, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 43, Col: 44}
}
_, 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, 13, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 45, Col: 60}
}
_, 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, 14, "</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -192,7 +200,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+53 -62
View File
@@ -1,15 +1,22 @@
package templates
templ CollectionModal(collection CollectionData) {
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
if collection.ID != "" {
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Collection</h2>
} else {
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
}
<button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("folder", "h-5 w-5")
</span>
if collection.ID != "" {
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Collection</h2>
} else {
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
}
</div>
<button type="button" @click="closeCollectionModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
if collection.ID != "" {
<form
@@ -18,65 +25,57 @@ templ CollectionModal(collection CollectionData) {
>
<input type="hidden" name="id" value={ collection.ID }/>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
<input
type="text"
name="name"
value={ collection.Name }
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="My Reading List"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
<textarea
name="description"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="Optional description"
rows="3"
>{ collection.Description }</textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon</label>
<!-- Search/Text Input -->
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Icon</label>
<input
type="text"
id="icon-search"
class="w-full px-4 py-2 border rounded-lg mb-2"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="🔍 Search or type emoji..."
class="input mb-2"
placeholder="Search or type emoji..."
maxlength="4"
oninput="filterIcons(this.value)"
onfocus="showAllIcons()"
@input="filterIcons($event.target.value)"
@focus="showAllIcons()"
/>
<!-- Hidden input for form submission -->
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
<!-- Emoji Grid -->
<div
id="icon-grid"
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg"
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border"
style="background-color: var(--bg-primary); border-color: var(--border);"
>
<!-- Populated dynamically by JavaScript -->
</div>
></div>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2">
<button type="button" @click="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
<button type="button" @click="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
<button type="button" @click="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
<button type="button" @click="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
<button type="button" @click="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
<button type="button" @click="selectColor('blue')" data-color="blue" class="w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400" aria-label="Blue"></button>
<button type="button" @click="selectColor('red')" data-color="red" class="w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400" aria-label="Red"></button>
<button type="button" @click="selectColor('yellow')" data-color="yellow" class="w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400" aria-label="Yellow"></button>
<button type="button" @click="selectColor('green')" data-color="green" class="w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400" aria-label="Green"></button>
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
</div>
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Update Collection</button>
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Update Collection</button>
</div>
</form>
} else {
@@ -85,64 +84,56 @@ templ CollectionModal(collection CollectionData) {
hx-redirect="/collections"
>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
<input
type="text"
name="name"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="My Reading List"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
<textarea
name="description"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="Optional description"
rows="3"
></textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon</label>
<!-- Search/Text Input -->
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Icon</label>
<input
type="text"
id="icon-search"
class="w-full px-4 py-2 border rounded-lg mb-2"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="🔍 Search or type emoji..."
class="input mb-2"
placeholder="Search or type emoji..."
maxlength="4"
oninput="filterIcons(this.value)"
onfocus="showAllIcons()"
@input="filterIcons($event.target.value)"
@focus="showAllIcons()"
/>
<!-- Hidden input for form submission -->
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
<!-- Emoji Grid -->
<div
id="icon-grid"
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg"
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border"
style="background-color: var(--bg-primary); border-color: var(--border);"
>
<!-- Populated dynamically by JavaScript -->
</div>
></div>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Color</label>
<div class="flex gap-2">
<button type="button" @click="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
<button type="button" @click="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
<button type="button" @click="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
<button type="button" @click="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
<button type="button" @click="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
<button type="button" @click="selectColor('blue')" data-color="blue" class="w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400" aria-label="Blue"></button>
<button type="button" @click="selectColor('red')" data-color="red" class="w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400" aria-label="Red"></button>
<button type="button" @click="selectColor('yellow')" data-color="yellow" class="w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400" aria-label="Yellow"></button>
<button type="button" @click="selectColor('green')" data-color="green" class="w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400" aria-label="Green"></button>
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
</div>
<input type="hidden" name="color" id="collection-color" value="blue"/>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button>
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Create Collection</button>
</div>
</form>
}
+37 -21
View File
@@ -29,128 +29,144 @@ func CollectionModal(collection CollectionData) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("folder", "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, 2, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if collection.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Collection</h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Collection</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Collection</h2>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Collection</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<button type=\"button\" @click=\"closeCollectionModal()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><button type=\"button\" @click=\"closeCollectionModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 6, "</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if collection.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<form hx-put=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 16, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
}
_, 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, 6, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 19, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 25, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Optional description\" rows=\"3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 40, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
}
_, 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, "</textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"w-full px-4 py-2 border rounded-lg mb-2\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"🔍 Search or type emoji...\" maxlength=\"4\" oninput=\"filterIcons(this.value)\" onfocus=\"showAllIcons()\"><!-- Hidden input for form submission --><input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 56, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
}
_, 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, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" class=\"w-8 h-8 rounded-full color-option bg-blue-500\"></button> <button type=\"button\" @click=\"selectColor('red')\" class=\"w-8 h-8 rounded-full color-option bg-red-500\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500\"></button> <button type=\"button\" @click=\"selectColor('green')\" class=\"w-8 h-8 rounded-full color-option bg-green-500\"></button> <button type=\"button\" @click=\"selectColor('purple')\" class=\"w-8 h-8 rounded-full color-option bg-purple-500\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 75, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
}
_, 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, 11, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Update Collection</button></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"w-full px-4 py-2 border rounded-lg mb-2\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"🔍 Search or type emoji...\" maxlength=\"4\" oninput=\"filterIcons(this.value)\" onfocus=\"showAllIcons()\"><!-- Hidden input for form submission --><input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 122, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 116, Col: 83}
}
_, 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, 13, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" class=\"w-8 h-8 rounded-full color-option bg-blue-500\"></button> <button type=\"button\" @click=\"selectColor('red')\" class=\"w-8 h-8 rounded-full color-option bg-red-500\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500\"></button> <button type=\"button\" @click=\"selectColor('green')\" class=\"w-8 h-8 rounded-full color-option bg-green-500\"></button> <button type=\"button\" @click=\"selectColor('purple')\" class=\"w-8 h-8 rounded-full color-option bg-purple-500\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Create Collection</button></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+39 -37
View File
@@ -14,11 +14,12 @@ templ CollectionRules(user User, collection CollectionData) {
@Header(user, "/collections")
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<button @click="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
Back to Collection
<button @click="backToCollection()" class="btn btn-secondary mb-4">
@Icon("arrow-left", "h-4 w-4")
Back to Collection
</button>
<div class="flex items-center gap-4">
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</span>
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
@@ -28,8 +29,10 @@ templ CollectionRules(user User, collection CollectionData) {
<div class="mb-8">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
<div id="rules-container" class="space-y-4">
<div id="no-rules" class="card p-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="text-4xl mb-2">📋</div>
<div id="no-rules" class="card p-8 text-center">
<span class="inline-grid place-items-center h-12 w-12 rounded-xl mb-3" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("filter", "h-6 w-6")
</span>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
</div>
@@ -37,16 +40,15 @@ templ CollectionRules(user User, collection CollectionData) {
</div>
<div class="mb-8">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
<form id="rule-form" @submit="handleCreateRule($event)">
<form id="rule-form" @submit="handleCreateRule($event)" class="card p-6 space-y-6">
<input type="hidden" id="collection-id" value="{ collection.ID }"/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Field</label>
<select
id="rule-field"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="">Select field...</option>
<option value="genre">Genre</option>
@@ -59,12 +61,11 @@ templ CollectionRules(user User, collection CollectionData) {
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Operator</label>
<select
id="rule-operator"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="">Select operator...</option>
<option value="equals">equals</option>
@@ -78,18 +79,17 @@ templ CollectionRules(user User, collection CollectionData) {
</select>
</div>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Value</label>
<input
type="text"
id="rule-value"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="e.g., Science Fiction"
/>
</div>
<div class="mb-6">
<div>
<label class="flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
@@ -97,16 +97,16 @@ templ CollectionRules(user User, collection CollectionData) {
checked
class="w-5 h-5 rounded"
/>
<span style="color: var(--text-primary)">Enable Rule</span>
<span class="text-sm font-medium" style="color: var(--text-primary)">Enable Rule</span>
</label>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Priority</label>
<div class="flex gap-4">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="radio" name="priority" value="1" class="w-4 h-4"/>
<span style="color: var(--text-primary)">High</span>
<span class="text-sm" style="color: var(--text-primary)">High</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input
@@ -116,7 +116,7 @@ templ CollectionRules(user User, collection CollectionData) {
checked
class="w-4 h-4"
/>
<span style="color: var(--text-primary)">Medium</span>
<span class="text-sm" style="color: var(--text-primary)">Medium</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input
@@ -125,36 +125,38 @@ templ CollectionRules(user User, collection CollectionData) {
value="3"
class="w-4 h-4"
/>
<span style="color: var(--text-primary)">Low</span>
<span class="text-sm" style="color: var(--text-primary)">Low</span>
</label>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
🧪 Test Rule
<button type="button" @click="testRule()" class="btn btn-secondary">
@Icon("search", "h-4 w-4")
Test Rule
</button>
<button type="button" @click="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
<button type="button" @click="clearForm()" class="btn btn-ghost">
Clear
</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
Add Rule
<button type="submit" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
Add Rule
</button>
</div>
</form>
<div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
<div id="test-results" class="hidden card p-6 mt-4 mb-6">
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3>
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p>
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
</div>
</div>
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="card p-6">
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
<div class="space-y-4">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">1</div>
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">1</span>
<div>
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p>
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
Field: genre
<br/>
Operator: equals
@@ -164,10 +166,10 @@ templ CollectionRules(user User, collection CollectionData) {
</div>
</div>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">2</div>
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">2</span>
<div>
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p>
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
Field: series
<br/>
Operator: starts with
@@ -177,10 +179,10 @@ templ CollectionRules(user User, collection CollectionData) {
</div>
</div>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">3</div>
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">3</span>
<div>
<p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p>
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
Field: copyright_year
<br/>
Operator: greater than
File diff suppressed because one or more lines are too long
+196 -134
View File
@@ -14,82 +14,89 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
</head>
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
@Header(user, "/collections")
<!-- Modal Container -->
<div id="modal-container"></div>
<!-- Actual container page -->
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8 flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
<p style="color: var(--text-secondary)">Organize your books into custom collections</p>
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8 flex flex-wrap justify-between items-center gap-4">
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("folder", "h-5 w-5")
</span>
<div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">My Collections</h1>
<p class="text-sm" style="color: var(--text-secondary)">Organize your books into custom collections</p>
</div>
</div>
<div class="flex gap-3">
<div class="flex gap-2">
<button
hx-get="/collections/restore-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-secondary px-4 py-2 rounded-lg"
class="btn btn-secondary"
>
🔄 Restore System
@Icon("refresh", "h-4 w-4")
<span>Restore System</span>
</button>
<button
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-primary px-4 py-2 rounded-lg"
class="btn btn-primary"
>
New Collection
@Icon("plus", "h-4 w-4")
<span>New Collection</span>
</button>
</div>
</div>
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
if len(collections) == 0 {
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<div class="card text-center py-16 px-6 col-span-full">
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("folder", "h-7 w-7")
</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
<p class="mb-4">Create collections to organize your books</p>
<p class="mb-5" style="color: var(--text-secondary)">Create collections to organize your books</p>
<button
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn-primary px-4 py-2 rounded-lg"
class="btn btn-primary"
>
Create Your First Collection
@Icon("plus", "h-4 w-4")
<span>Create Your First Collection</span>
</button>
</div>
}
for _, col := range collections {
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
<div
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary);"
class="card p-6 rounded-2xl border-l-4 cursor-pointer"
data-color={ col.Color }
>
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">{ col.Icon }</div>
<div class="flex space-x-2">
<div class="flex gap-1">
<button
hx-get={ "/collections/" + col.ID + "/edit-modal" }
hx-target="#modal-container"
hx-swap="innerHTML"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);"
class="icon-btn"
aria-label="Edit collection"
>
✏️
@Icon("edit", "h-4 w-4")
</button>
<button
hx-delete={ "/api/collections/" + col.ID + "" }
hx-redirect="/collections"
hx-confirm="Are you sure you want to delete this collection?"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-secondary); background-color: var(--bg-primary);"
class="icon-btn"
aria-label="Delete collection"
>
🗑️
@Icon("trash", "h-4 w-4")
</button>
</div>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ col.Description }</p>
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p>
</div>
</div>
}
@@ -113,123 +120,128 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
@Header(user, "/collections")
@LibrarySwitcher(libData, libraryID)
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<a href="/collections" class="btn-secondary px-4 py-2 rounded-lg mb-4 inline-block">
Back to Collections
<a href="/collections" class="btn btn-secondary mb-4">
@Icon("arrow-left", "h-4 w-4")
<span>Back to Collections</span>
</a>
<div class="flex items-center gap-4">
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</div>
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
<p style="color: var(--text-secondary)">{ collection.Description }</p>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ collection.Name }</h1>
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
</div>
</div>
</div>
<div class="mb-6 flex justify-between items-center">
<div class="flex items-center gap-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
0 selected
</span>
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
<div class="flex items-center gap-3">
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Books in this Collection</h2>
<span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span>
</div>
<div class="flex gap-3">
<div class="flex-1 max-w-md">
<div class="flex flex-wrap gap-2 items-center">
<div class="flex-1 min-w-[200px] max-w-md">
<input
type="text"
id="collection-search"
placeholder="Search within collection..."
onkeyup="filterCollectionBooks()"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
@input="filterCollectionBooks()"
class="input"
/>
</div>
<button
id="bulk-remove-btn"
disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
🗑️ Remove Selected
</button>
<button
@click="$store.bookPicker.open()"
class="btn-primary px-4 py-2 rounded-lg"
>
Add Books
</button>
if !collection.IsSystem {
<button
@click="requestBulkRemove()"
:disabled="selectedBooks.length === 0"
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
class="btn btn-danger"
>
@Icon("trash", "h-4 w-4")
<span>Remove Selected</span>
</button>
<button
@click="$store.bookPicker.open()"
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4")
<span>Add Books</span>
</button>
}
</div>
</div>
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
if len(books) == 0 {
<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
<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 {
<a href={ "/media/" + book.MediaItemID }>
<div
class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<div class="flex gap-4">
<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 {
<div class="flex-shrink-0 pt-1">
<input
type="checkbox"
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
class="w-5 h-5"
/>
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
class="w-5 h-5"
:checked="selectedBooks.includes('{ book.MediaItemID }')"
@change="toggleSelection('{ book.MediaItemID }')"
/>
</label>
</div>
<div class="flex-1 min-w-0">
<h3
class="font-semibold text-lg mb-1 line-clamp-2"
style="color: var(--text-primary)"
>
}
<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>
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>
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 shadow-md"
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 shadow-md"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
}
</div>
</div>
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click="removeBook('{ book.MediaItemID }')"
class="px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);"
>
🗑️ Remove from Collection
</button>
</a>
</div>
</div>
</a>
if !collection.IsSystem {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click="requestRemoveBook('{ book.MediaItemID }')"
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
}
</div>
}
</div>
</div>
<!-- Book Picker Modal -->
<div
x-data="bookPicker"
if !collection.IsSystem {
<div
x-data="bookPicker"
x-show="$store.bookPicker.isOpen"
@keyup.escape.window="$store.bookPicker.close()"
class="fixed inset-0 z-50 flex items-center justify-center"
style="display: none;"
@click.self="$store.bookPicker.close()"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: none; background-color: var(--surface-overlay);"
>
<div
@click.stop
@@ -240,8 +252,8 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="card rounded-lg w-full max-w-6xl mx-4 my-8"
style="background-color: var(--bg-secondary); border-color: var(--border); display: none;"
class="card rounded-2xl w-full max-w-6xl mx-4 my-8"
style="display: none; box-shadow: var(--shadow-pop);"
>
<div
class="flex justify-between items-center p-6 border-b"
@@ -252,23 +264,24 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</h2>
<button
@click="$store.bookPicker.close()"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-primary)"
></button>
class="icon-btn"
aria-label="Close"
>
@Icon("close", "h-5 w-5")
</button>
</div>
<div
class="p-4 border-b"
style="border-color: var(--border);"
>
<div class="flex flex-wrap gap-4 items-center">
<div id="book-picker-filters" class="flex flex-wrap gap-3 items-center">
<div class="flex-1 min-w-[200px]">
<input
type="text"
name="search"
name="q"
placeholder="Search books..."
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
class="input"
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="keyup changed delay:300ms"
hx-include="#book-picker-filters"
@@ -279,9 +292,8 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
type="text"
name="author_filter"
placeholder="Author"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
class="input"
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="change"
hx-include="#book-picker-filters"
@@ -292,32 +304,30 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
type="text"
name="genre_filter"
placeholder="Genre"
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
class="input"
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
hx-target="#book-picker-grid"
hx-trigger="change"
hx-include="#book-picker-filters"
/>
</div>
<div>
<button
@click="$store.bookPicker.clearFilters()"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Clear
</button>
</div>
</div>
<form id="filter-form" class="hidden">
<button
@click="$store.bookPicker.clearFilters()"
class="btn btn-ghost"
>
@Icon("close", "h-4 w-4")
<span>Clear</span>
</button>
<input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/>
</form>
</div>
</div>
<div
id="book-picker-grid"
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
hx-trigger="loadBooks"
hx-include="#book-picker-filters"
>
</div>
<div
@@ -330,15 +340,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<div class="flex gap-2">
<button
@click="$store.bookPicker.close()"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
class="btn btn-secondary"
>
Cancel
</button>
<button
@click="$store.bookPicker.submit()"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent); color: var(--bg-primary);"
class="btn btn-primary"
>
Add Selected Books
</button>
@@ -346,12 +354,66 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div>
</div>
</div>
}
<div
x-show="showConfirm"
@click.self="closeConfirm()"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="display: none; background-color: var(--surface-overlay);"
>
<div
@click.stop
class="card p-6 w-full max-w-md mx-4"
style="box-shadow: var(--shadow-pop);"
>
<h3 class="text-lg font-bold mb-2" style="color: var(--text-primary)">Remove from Collection</h3>
<p x-text="confirmMessage" class="text-sm mb-6" style="color: var(--text-secondary)"></p>
<div class="flex justify-end gap-3">
<button @click="closeConfirm()" class="btn btn-secondary">Cancel</button>
<button @click="executeConfirmed()" class="btn btn-danger">
@Icon("trash", "h-4 w-4")
<span x-text="confirmLabel"></span>
</button>
</div>
</div>
</div>
</body>
<div
id="collection-data"
data-id={ collection.ID }
data-library-id={ libraryID }
data-is-system={ collection.IsSystem }
style="display: none;"
></div>
</html>
}
templ BookPickerGrid(books []handlers.BookInfo) {
for _, book := range books {
<div
class="relative cursor-pointer rounded-lg overflow-hidden"
@click={ "$store.bookPicker.toggleBook('" + book.MediaItemID + "')" }
>
<div class="relative">
if book.CoverImagePath != "" {
<img src={ book.CoverImagePath } alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy" onerror="this.src='/static/placeholder-book.svg'"/>
} else {
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/>
}
<div
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
class="absolute inset-0 border-4 rounded-lg pointer-events-none"
style="border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);"
></div>
<div
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
class="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none"
style="background-color: var(--accent); color: var(--bg-primary);"
>
</div>
</div>
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</p>
</div>
}
}
+423 -69
View File
@@ -39,114 +39,170 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container --><div id=\"modal-container\"></div><!-- Actual container page --><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex justify-between items-center\"><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">My Collections</h1><p style=\"color: var(--text-secondary)\">Organize your books into custom collections</p></div><div class=\"flex gap-3\"><button hx-get=\"/collections/restore-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-secondary px-4 py-2 rounded-lg\">🔄 Restore System</button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-primary px-4 py-2 rounded-lg\"> New Collection</button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"modal-container\"></div><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex flex-wrap justify-between items-center gap-4\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("folder", "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, 3, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">My Collections</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Organize your books into custom collections</p></div></div><div class=\"flex gap-2\"><button hx-get=\"/collections/restore-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span>Restore System</span></button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>New Collection</span></button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(collections) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"text-center py-16 col-span-full\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📚</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Collections Yet</h3><p class=\"mb-4\">Create collections to organize your books</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-primary px-4 py-2 rounded-lg\">Create Your First Collection</button></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card text-center py-16 px-6 col-span-full\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("folder", "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, 7, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Collections Yet</h3><p class=\"mb-5\" style=\"color: var(--text-secondary)\">Create collections to organize your books</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span>Create Your First Collection</span></button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, col := range collections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div @click=\"navigateToCollection($el)\" data-href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div @click=\"navigateToCollection($el)\" data-href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 62, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 70, Col: 82}
}
_, 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, "\" class=\"block\"><div class=\"card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary);\" data-color=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"block\"><div class=\"card p-6 rounded-2xl border-l-4 cursor-pointer\" data-color=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 73, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 69, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 76, Col: 41}
}
_, 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, 7, "</div><div class=\"flex space-x-2\"><button hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"flex gap-1\"><button hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 72, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 79, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-secondary); background-color: var(--bg-primary);\">✏️</button> <button hx-delete=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn\" aria-label=\"Edit collection\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button> <button hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 81, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 88, Col: 56}
}
_, 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, 9, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-secondary); background-color: var(--bg-primary);\">🗑️</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"icon-btn\" aria-label=\"Delete collection\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 91, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 98, Col: 92}
}
_, 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, 10, "</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 92, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 81}
}
_, 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, 11, "</p></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -154,7 +210,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -183,20 +239,20 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 109, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 116, Col: 27}
}
_, 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, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -208,165 +264,326 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn-secondary px-4 py-2 rounded-lg mb-4 inline-block\">← Back to Collections</a><div class=\"flex items-center gap-4\"><div class=\"text-4xl\" style=\"color: { collection.Color }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn btn-secondary mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span>Back to Collections</span></a><div class=\"flex items-center gap-4\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl text-3xl\" style=\"background-color: var(--accent-muted); color: { collection.Color }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 122, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 130, Col: 166}
}
_, 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, 17, "</div><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 124, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 105}
}
_, 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, 18, "</h1><p style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 125, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 133, Col: 87}
}
_, 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, 19, "</p></div></div></div><div class=\"mb-6 flex justify-between items-center\"><div class=\"flex items-center gap-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span id=\"selected-count\" class=\"hidden px-3 py-1 text-sm rounded\" style=\"background-color: var(--accent); color: var(--bg-primary);\">0 selected</span></div><div class=\"flex gap-3\"><div class=\"flex-1 max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" onkeyup=\"filterCollectionBooks()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><button id=\"bulk-remove-btn\" disabled class=\"btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed\">🗑️ Remove Selected</button> <button @click=\"$store.bookPicker.open()\" class=\"btn-primary px-4 py-2 rounded-lg\"> Add Books</button></div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div></div><div class=\"mb-6 flex flex-wrap justify-between items-center gap-4\"><div class=\"flex items-center gap-3\"><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span x-show=\"selectedBooks.length > 0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\"></span></div><div class=\"flex flex-wrap gap-2 items-center\"><div class=\"flex-1 min-w-[200px] max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" @input=\"filterCollectionBooks()\" class=\"input\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !collection.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<button @click=\"requestBulkRemove()\" :disabled=\"selectedBooks.length === 0\" :class=\"selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''\" class=\"btn btn-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Remove Selected</span></button> <button @click=\"$store.bookPicker.open()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span>Add Books</span></button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(books) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div id=\"empty-state\" class=\"col-span-full text-center py-16\" style=\"color: var(--text-secondary)\">No books in this collection yet.</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, book := range books {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card p-4 rounded-2xl collection-book-card\" data-title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 167, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><div class=\"card p-4 rounded-lg border hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex gap-4\"><div class=\"flex-shrink-0 pt-1\"><input type=\"checkbox\" onchange=\"toggleBookForRemoval('{ book.MediaItemID }')\" class=\"w-5 h-5\"></div><div class=\"flex-1 min-w-0\"><h3 class=\"font-semibold text-lg mb-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" data-author=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 185, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h3>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-media-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 177, Col: 147}
}
_, 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, 36, "\"><div class=\"flex gap-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !collection.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<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.MediaItemID }')\" @change=\"toggleSelection('{ book.MediaItemID }')\"></label></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex-1 min-w-0\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"><h3 class=\"font-semibold text-lg mb-1 line-clamp-2 hover:underline\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 204, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 207, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></div><div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"removeBook('{ book.MediaItemID }')\" class=\"px-3 py-1 text-sm border rounded hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-secondary);\">🗑️ Remove from Collection</button></div></div></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !collection.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"requestRemoveBook('{ book.MediaItemID }')\" class=\"btn btn-secondary w-full\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span>Remove from Collection</span></button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div><!-- Book Picker Modal --><div x-data=\"bookPicker\" @keyup.escape.window=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center\" style=\"display: none;\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-lg w-full max-w-6xl mx-4 my-8\" style=\"background-color: var(--bg-secondary); border-color: var(--border); display: none;\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"search\" placeholder=\"Search books...\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div><button @click=\"$store.bookPicker.clearFilters()\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">✕ Clear</button></div></div><form id=\"filter-form\" class=\"hidden\"><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent); color: var(--bg-primary);\">Add Selected Books</button></div></div></div></div></body><div id=\"collection-data\" data-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 352, Col: 26}
if !collection.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div x-data=\"bookPicker\" x-show=\"$store.bookPicker.isOpen\" @keyup.escape.window=\"$store.bookPicker.close()\" @click.self=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-2xl w-full max-w-6xl mx-4 my-8\" style=\"display: none; box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 54, "</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div id=\"book-picker-filters\" class=\"flex flex-wrap gap-3 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"q\" placeholder=\"Search books...\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><button @click=\"$store.bookPicker.clearFilters()\" class=\"btn btn-ghost\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>Clear</span></button> <input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></div></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-trigger=\"loadBooks\" hx-include=\"#book-picker-filters\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"btn btn-primary\">Add Selected Books</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div x-show=\"showConfirm\" @click.self=\"closeConfirm()\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><h3 class=\"text-lg font-bold mb-2\" style=\"color: var(--text-primary)\">Remove from Collection</h3><p x-text=\"confirmMessage\" class=\"text-sm mb-6\" style=\"color: var(--text-secondary)\"></p><div class=\"flex justify-end gap-3\"><button @click=\"closeConfirm()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"executeConfirmed()\" class=\"btn btn-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" data-library-id=\"")
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span x-text=\"confirmLabel\"></span></button></div></div></div></body><div id=\"collection-data\" data-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" style=\"display: none;\"></div></html>")
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 383, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-library-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 384, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" data-is-system=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" style=\"display: none;\"></div></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -374,4 +591,141 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
})
}
func BookPickerGrid(books []handlers.BookInfo) 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_Var25 := templ.GetChildren(ctx)
if templ_7745c5c3_Var25 == nil {
templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
for _, book := range books {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"relative cursor-pointer rounded-lg overflow-hidden\" @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 395, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\"><div class=\"relative\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 399, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 399, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<img src=\"/static/placeholder-book.svg\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div x-show=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 404, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" class=\"absolute inset-0 border-4 rounded-lg pointer-events-none\" style=\"border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);\"></div><div x-show=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 409, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" class=\"absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none\" style=\"background-color: var(--accent); color: var(--bg-primary);\">✓</div></div><p class=\"text-xs mt-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 416, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+82 -69
View File
@@ -14,55 +14,66 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</head>
<body x-data="conflicts" class="theme-{ user.Theme }">
@Header(user, "/conflicts")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<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">
<div class="flex justify-between items-center gap-4 flex-wrap">
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1>
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
<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="px-4 py-2 border rounded-lg"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
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-secondary px-4 py-2 rounded-lg">
Dismiss All Resolved
</button>
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button>
</div>
</div>
<div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
<span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span>
<span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
<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="flex items-center justify-between p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<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="px-3 py-2 border rounded-lg text-sm"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
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-primary px-4 py-2 rounded-lg text-sm">
Resolve Selected
<button @click="bulkResolve()" class="btn btn-primary text-sm">
@Icon("check", "h-4 w-4")
Resolve Selected
</button>
<button @click="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
Dismiss Selected
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
Dismiss Selected
</button>
</div>
</div>
@@ -72,12 +83,14 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<div class="loading-spinner mx-auto mb-4"></div>
<p>Loading conflicts...</p>
</div>
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4"></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>Your devices are in sync! No conflicts to resolve.</p>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
<div id="conflicts-list" class="space-y-6">
<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()"/>
@@ -85,15 +98,17 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</label>
</div>
if len(conflicts) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4"></div>
<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);">
@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>Your devices are in sync! No conflicts to resolve.</p>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div>
}
for _, conflict := range conflicts {
<div class="conflict-item card p-6 rounded-lg border" data-conflict-id="{ conflict.ID }" style="background-color: var(--bg-secondary); border-color: var(--border); border-left-width: 4px; border-left-style: solid; border-left-color: rgb(245, 158, 11);">
<div class="flex justify-between items-start mb-4">
<div class="conflict-item card p-6" data-conflict-id="{ conflict.ID }" 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"
@@ -101,14 +116,17 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
data-conflict-id="{ conflict.ID }"
onchange="updateBulkActions()"
/>
<div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
<div class="flex items-start gap-3">
<span style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
</span>
<div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
</div>
</div>
</div>
<button @click="showResolveModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
Resolve
</button>
<button @click="showResolveModal('{ conflict.ID }')" class="btn btn-primary">Resolve</button>
</div>
<div class="text-sm" style="color: var(--text-secondary)">
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
@@ -119,94 +137,90 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</div>
}
</div>
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
<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-2xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
<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="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
X
<button @click="hideResolveModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</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 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
<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 p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<input type="radio" name="winner" value="koreader" class="mr-3" required/>
<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 p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<input type="radio" name="winner" value="kobo" class="mr-3"/>
<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 p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<input type="radio" name="winner" value="web" class="mr-3"/>
<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 p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
<input type="radio" name="winner" value="manual" class="mr-3"/>
<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 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
<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-sm font-medium mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label>
<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="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label>
<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="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
<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="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label>
<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="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
</div>
@@ -218,20 +232,19 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</label>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
<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="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="e.g., Device was offline, using most recent progress"
/>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="hideResolveModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="button" @click="deleteConflict()" class="btn-danger px-4 py-2 rounded-lg">Dismiss</button>
<button type="submit" class="btn-primary px-6 py-2 rounded-lg">Resolve Conflict</button>
<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>
File diff suppressed because one or more lines are too long
+60 -53
View File
@@ -12,75 +12,82 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/custom-section")
<main class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
<p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
<div class="mb-8">
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("layers", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Create Custom Section</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
</div>
<form id="custom-section-form" class="space-y-6">
<!-- Section Details -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
<div class="card p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
@Icon("info", "h-5 w-5")
Section Details
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
<label for="section-name" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
<input
type="text"
id="section-name"
name="name"
required
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
<label for="section-icon" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
<input
type="text"
id="section-icon"
name="icon"
maxlength="4"
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="📚"
/>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<label for="section-description" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<textarea
id="section-description"
name="description"
rows="2"
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
></textarea>
</div>
<div class="mt-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
<label for="section-library" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
<select
id="section-library"
name="library_id"
required
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="">Select a library...</option>
<option value="">Select a library</option>
for _, lib := range libraries {
<option value={ lib.ID }>{ lib.Name }</option>
}
</select>
</div>
</div>
<!-- Filter Rules -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
<h2 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
@Icon("filter", "h-5 w-5")
Filter Rules
</h2>
<button
type="button"
id="add-rule-btn"
class="px-3 py-1 rounded-lg text-sm font-medium"
style="background-color: var(--accent);"
class="btn btn-primary text-sm"
>
+ Add Rule
@Icon("plus", "h-4 w-4")
Add Rule
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary)">
@@ -88,101 +95,101 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
</p>
<div id="rules-container" class="space-y-3"></div>
<div class="mt-4 flex items-center gap-2">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
<label for="match-type" class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Match:</label>
<select
id="match-type"
name="match_type"
class="px-3 py-1 rounded border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input w-auto"
>
<option value="all">ALL rules (AND)</option>
<option value="any">ANY rule (OR)</option>
</select>
</div>
</div>
<!-- Manual Book Selection -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
<div class="card p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
@Icon("book", "h-5 w-5")
Manual Book Selection
</h2>
<p class="text-sm mb-4" style="color: var(--text-secondary)">
Add specific books to this section. Use the search to find and select multiple books.
</p>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
<label for="book-search" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
<div class="flex gap-2">
<input
type="text"
id="book-search"
name="book_search"
class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search by title or author..."
class="input flex-1"
placeholder="Search by title or author…"
autocomplete="off"
/>
<button
type="button"
id="search-books-btn"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent);"
class="btn btn-primary"
>
@Icon("search", "h-4 w-4")
Search
</button>
</div>
</div>
<div
id="search-results"
class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2"
style="background-color: var(--bg-primary);"
></div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
<label class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
<div
id="selected-books"
class="min-h-[60px] p-3 rounded-lg border-2 border-dashed"
style="border-color: var(--border); background-color: var(--bg-primary);"
class="min-h-[60px] p-3 rounded-xl border-2 border-dashed"
style="border-color: var(--border-strong); background-color: var(--bg-primary);"
>
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
</div>
</div>
</div>
<!-- Live Preview -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
<h2 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
@Icon("play", "h-5 w-5")
Live Preview
</h2>
<button
type="button"
id="preview-btn"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent);"
class="btn btn-secondary text-sm"
>
@Icon("refresh", "h-4 w-4")
Refresh Preview
</button>
</div>
<div
id="preview-container"
class="p-4 rounded-lg"
class="card p-4"
style="background-color: var(--bg-primary); min-height: 200px;"
>
<p class="text-center" style="color: var(--text-secondary);">
<p class="text-center text-sm" style="color: var(--text-secondary);">
Add filter rules or select books to see a preview of your custom section.
</p>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-end gap-3">
<button
type="button"
id="cancel-btn"
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
class="btn btn-secondary"
>
Cancel
</button>
<button
type="submit"
id="save-section-btn"
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
style="background-color: var(--accent);"
class="btn btn-primary"
>
@Icon("save", "h-4 w-4")
Save Section
</button>
</div>
+80 -8
View File
@@ -37,43 +37,115 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Create Custom Section</h1><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p><form id=\"custom-section-form\" class=\"space-y-6\"><!-- Section Details --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><h2 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></textarea></div><div class=\"mt-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select a library...</option> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("layers", "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, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Create Custom Section</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p></div><form id=\"custom-section-form\" class=\"space-y-6\"><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "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, 4, "Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label for=\"section-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"input\"></div><div><label for=\"section-icon\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"input\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label for=\"section-description\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"input\"></textarea></div><div class=\"mt-4\"><label for=\"section-library\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"input\"><option value=\"\">Select a library…</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, lib := range libraries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 31}
}
_, 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, 4, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 73, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</option>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</select></div></div><!-- Filter Rules --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"px-3 py-1 rounded-lg text-sm font-medium\" style=\"background-color: var(--accent);\">+ Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"px-3 py-1 rounded border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><!-- Manual Book Selection --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><h2 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Search by title or author...\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent);\">Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-lg border-2 border-dashed\" style=\"border-color: var(--border); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><!-- Live Preview --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent);\">Refresh Preview</button></div><div id=\"preview-container\" class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><!-- Form Actions --><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"px-6 py-2 rounded-lg font-medium border hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"px-6 py-2 rounded-lg font-medium text-white hover:opacity-90\" style=\"background-color: var(--accent);\">Save Section</button></div></form></main>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("filter", "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, 9, "Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label for=\"match-type\" class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"input w-auto\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "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, 11, "Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label for=\"book-search\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"input flex-1\" placeholder=\"Search by title or author…\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("play", "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, 13, "Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Refresh Preview</button></div><div id=\"preview-container\" class=\"card p-4\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center text-sm\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Save Section</button></div></form></main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -81,7 +153,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+77 -89
View File
@@ -18,13 +18,15 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
@Header(user, "/dashboard")
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
<!-- Collections Container -->
<main id="collections-container" class="w-full px-4 py-8">
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p>
</div>
for _, section := range sections {
@CollectionCarousel(section)
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
@ErrorToast(errorMessage)
</body>
@@ -33,89 +35,79 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
templ CollectionCarousel(section handlers.SectionData) {
<div
class="dashboard-collection mb-8"
class="dashboard-collection mb-10"
data-collection-id={ section.ID }
data-is-system={ section.IsSystem }
>
<!-- Collection Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-end justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">{ section.Icon }</span>
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted);">{ section.Icon }</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">{ section.Title }</h2>
if section.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
<p class="text-xs" style="color: var(--text-secondary)">{ section.Description }</p>
}
</div>
</div>
if section.ViewAllURL != "" {
<a
href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors"
class="inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);"
>
View All
View All
@Icon("arrow-right", "h-4 w-4")
</a>
}
</div>
<!-- Carousel -->
<div class="carousel-container relative group">
<button
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="-1"
aria-label="Scroll left"
style="background: linear-gradient(to right, var(--bg-primary), transparent);"
>
<span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
@Icon("chevron-left", "h-7 w-7")
</button>
<div
id={ "carousel-track-" + section.ID }
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
class="carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2"
style="scrollbar-width: none; -ms-overflow-style: none;"
>
for _, item := range section.Items {
@BookCard(item)
<div class="flex-shrink-0 w-36 sm:w-40 snap-start">
@BookCard(item)
</div>
}
if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
<p>No items in this collection</p>
<div class="flex flex-col items-center justify-center text-center py-12 w-full gap-2" style="color: var(--text-secondary);">
@Icon("book", "h-8 w-8 opacity-50")
<p class="text-sm">No items in this collection</p>
</div>
}
</div>
<button
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="1"
aria-label="Scroll right"
style="background: linear-gradient(to left, var(--bg-primary), transparent);"
>
<span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
@Icon("chevron-right", "h-7 w-7")
</button>
</div>
</div>
}
templ BookCard(item handlers.BookInfo) {
<a href={ "/media/" + item.MediaItemID } title={ item.Title }>
<div
class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
tabindex="0"
role="button"
aria-label={ "View " + item.Title }
>
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer">
<a href={ "/media/" + item.MediaItemID } class="block h-full">
<div
class="aspect-[2/3] overflow-hidden shadow-lg
bg-gradient-to-br from-gray-700 to-gray-900"
class="book-card-cover aspect-[2/3] overflow-hidden"
style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);"
>
if item.CoverImagePath != "" {
<img
@@ -133,60 +125,72 @@ templ BookCard(item handlers.BookInfo) {
/>
}
</div>
<div class="book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]">
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
<div class="book-card-meta">
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
{ item.Title }
</h3>
if item.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
<p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
{ item.Author }
</p>
}
</div>
</a>
<div class="book-card-action">
if item.HasConflict {
<a
href={ "/media/" + item.MediaItemID }
class="book-card-action-btn"
aria-label={ "Resolve progress conflict for " + item.Title }
title="Resolve progress conflict"
>
@Icon("book-open", "h-5 w-5")
</a>
} else {
<a
href={ "/readers/" + item.MediaItemID }
class="book-card-action-btn"
aria-label={ "Read " + item.Title }
title="Read"
>
@Icon("book-open", "h-5 w-5")
</a>
}
</div>
</a>
</div>
}
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
<div
id="dashboard-settings-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);"
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div
class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);"
>
<div class="card p-6 w-full max-w-2xl shadow-2xl" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<button
data-action="close-dashboard-settings"
class="p-2 hover:bg-gray-700 rounded transition-colors"
>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
</div>
<button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Drag to reorder collections, toggle visibility with the switch.
</p>
<!-- Draggable Collection List -->
<div id="collection-list" class="space-y-2 mb-6">
for _, section := range sections {
<div
class="collection-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card"
data-collection-id={ section.ID }
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);"
>
<div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);"></span>
@Icon("grip", "h-5 w-5 shrink-0")
<span class="text-xl">{ section.Icon }</span>
<div>
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
<div class="flex items-center gap-2">
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
if section.IsSystem {
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
}
</div>
</div>
@@ -195,8 +199,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
<button
data-action="restore-system-collection"
data-collection-name={ section.ID }
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-secondary);"
class="btn btn-secondary py-1 px-2.5 text-xs"
title="Restore { section.Title } to defaults"
>
Restore
@@ -211,21 +214,17 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
}
/>
<div
class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"
class="w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
style="background-color: var(--border-strong);"
></div>
</label>
</div>
</div>
}
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Collection: <span id="items-count-display" class="font-bold">{ itemsPerSection }</span>
Items per Collection: <span id="items-count-display" class="font-bold" style="color: var(--text-primary)">{ itemsPerSection }</span>
</label>
<input
type="range"
@@ -233,26 +232,15 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
max="50"
step="5"
value={ itemsPerSection }
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
class="w-full h-2 rounded-lg appearance-none cursor-pointer"
style="background-color: var(--border-strong);"
data-input-action="update-items-count"
target="items-count-display"
/>
</div>
<div class="flex justify-end gap-3">
<button
data-action="close-dashboard-settings"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);"
>
Cancel
</button>
<button
data-action="save-dashboard-settings"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);"
>
Save Changes
</button>
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
+283 -170
View File
@@ -46,7 +46,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Collections Container --><main id=\"collections-container\" class=\"w-full px-4 py-8\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main id=\"collections-container\" class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Your Library</h1><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Pick up where you left off, or explore something new.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -56,7 +56,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main><!-- Dashboard Settings Modal -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -97,14 +97,14 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-8\" data-collection-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-10\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 39, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -117,33 +117,33 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><!-- Collection Header --><div class=\"flex items-center justify-between mb-4\"><div class=\"flex items-center gap-3\"><span class=\"text-2xl\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"flex items-end justify-between mb-4\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-9 w-9 rounded-lg text-lg\" style=\"background-color: var(--accent-muted);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 43, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 44, Col: 130}
}
_, 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, 8, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 46, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -154,14 +154,14 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
return templ_7745c5c3_Err
}
if section.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -184,73 +184,113 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 53, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 30}
}
_, 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, 14, "\" class=\"text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All →</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("arrow-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div><!-- Carousel --><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 w-12 h-full bg-gradient-to-r from-gray-900 to-transparent flex items-center justify-start opacity-0 group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 67, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-direction=\"-1\" aria-label=\"Scroll left\"><span class=\"text-3xl pl-2\" style=\"color: var(--text-primary);\"></span></button><div id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-direction=\"-1\" aria-label=\"Scroll left\" style=\"background: linear-gradient(to right, var(--bg-primary), transparent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-left", "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, 18, "</button><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 75, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-12 pb-4\" style=\"scrollbar-width: none; -ms-overflow-style: none;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2\" style=\"scrollbar-width: none; -ms-overflow-style: none;\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range section.Items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"flex-shrink-0 w-36 sm:w-40 snap-start\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BookCard(item).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(section.Items) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"text-center py-8 w-full\" style=\"color: var(--text-secondary);\"><p>No items in this collection</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"flex flex-col items-center justify-center text-center py-12 w-full gap-2\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "h-8 w-8 opacity-50").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"text-sm\">No items in this collection</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><button class=\"carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 w-12 h-full bg-gradient-to-l from-gray-900 to-transparent flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><button class=\"carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 94, Col: 35}
}
_, 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, 20, "\" data-direction=\"1\" aria-label=\"Scroll right\"><span class=\"text-3xl pr-2\" style=\"color: var(--text-primary);\"></span></button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-direction=\"1\" aria-label=\"Scroll right\" style=\"background: linear-gradient(to left, var(--bg-primary), transparent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-right", "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, 26, "</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -279,162 +319,219 @@ func BookCard(item handlers.BookInfo) templ.Component {
templ_7745c5c3_Var12 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 107, Col: 40}
}
_, 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, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"><div class=\"book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer transition-transform duration-200 hover:scale-105\" tabindex=\"0\" role=\"button\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("View " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"><div class=\"aspect-[2/3] overflow-hidden shadow-lg bg-gradient-to-br from-gray-700 to-gray-900\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"block h-full\"><div class=\"book-card-cover aspect-[2/3] overflow-hidden\" style=\"background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<img src=\"/static/placeholder-book.svg\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 122, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22}
}
_, 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, 26, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<img src=\"/static/placeholder-book.svg\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"w-full h-full object-cover\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"w-full h-full object-cover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div><div class=\"book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]\"><h3 class=\"font-semibold text-base line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div><div class=\"book-card-meta\"><h3 class=\"font-semibold text-sm leading-snug line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 129, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h3>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<p class=\"text-xs mt-0.5 line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 141, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 133, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 142, Col: 19}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></div></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div></a><div class=\"book-card-action\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.HasConflict {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 142, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" class=\"book-card-action-btn\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 144, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" title=\"Resolve progress conflict\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "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, 44, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 151, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"book-card-action-btn\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 153, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" title=\"Read\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "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, 48, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -458,143 +555,159 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var23 := templ.GetChildren(ctx)
if templ_7745c5c3_Var23 == nil {
templ_7745c5c3_Var23 = templ.NopComponent
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
if templ_7745c5c3_Var25 == nil {
templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><button data-action=\"close-dashboard-settings\" class=\"p-2 hover:bg-gray-700 rounded transition-colors\">✕</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary);\">Drag to reorder collections, toggle visibility with the switch.</p><!-- Draggable Collection List --><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" 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)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 51, "</button></div><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, section := range sections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"collection-item flex items-center justify-between p-3 rounded border cursor-move select-none\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-is-system=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" draggable=\"true\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"text-xl\" style=\"color: var(--text-secondary);\">☰</span> <span class=\"text-xl\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 183, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span><div><span class=\"font-medium\" style=\"color: var(--text-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" data-is-system=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 187, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" draggable=\"true\"><div class=\"flex items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("grip", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"text-xl\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 189, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span><div class=\"flex items-center gap-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 191, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"text-xs ml-2 px-2 py-1 rounded\" style=\"background-color: var(--accent);\">System</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div><div class=\"flex items-center gap-3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div><div class=\"flex items-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 201, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity\" style=\"border-color: var(--border); color: var(--text-secondary);\" title=\"Restore { section.Title } to defaults\">Restore</button> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" class=\"btn btn-secondary py-1 px-2.5 text-xs\" title=\"Restore { section.Title } to defaults\">Restore</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !ContainsString(hiddenCollections, section.ID) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " checked")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><div class=\"w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600\"></div></label></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "><div class=\"w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all\" style=\"background-color: var(--border-strong);\"></div></label></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div><!-- Items Per Section Slider --><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 227, Col: 128}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 234, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors\" style=\"border-color: var(--border); color: var(--text-primary);\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity\" style=\"background-color: var(--accent);\">Save Changes</button></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+177 -156
View File
@@ -14,56 +14,58 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</head>
<body class="theme-{ user.Theme }" x-data="devices" x-init="setupEventDelegation()">
@Header(user, "/devices")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<!-- Header Section -->
<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">
<div class="flex justify-between items-center gap-4 flex-wrap">
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Device Management</h1>
<p style="color: var(--text-secondary)">Manage your reading devices and sync settings</p>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("device", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Device Management</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Manage your reading devices and sync settings</p>
</div>
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
Add New Device
<button @click="showAddDeviceModal()" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
Add New Device
</button>
</div>
</div>
<!-- Devices Grid -->
<div id="devices-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Empty state -->
if len(devices) == 0 {
<div id="empty-state" class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📱</div>
<div id="empty-state" class="card text-center py-16 col-span-full">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("device", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Devices Yet</h3>
<p class="mb-4">Add your reading devices to enable cross-device sync</p>
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
Add Your First Device
</button>
<p class="mb-4 text-sm" style="color: var(--text-secondary)">Add your reading devices to enable cross-device sync</p>
<button @click="showAddDeviceModal()" class="btn btn-primary">Add Your First Device</button>
</div>
}
<!-- Devices Grid -->
if len(devices) > 0 {
<div id="devices-grid" class="grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div id="devices-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 col-span-full">
for _, device := range devices {
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card p-6">
<div class="flex items-start justify-between mb-4">
<div class="text-4xl">
<span class="grid place-items-center h-11 w-11 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
if device.DeviceType == "koreader" {
📖
@Icon("book-open", "h-5 w-5")
} else if device.DeviceType == "kobo" {
📚
@Icon("library", "h-5 w-5")
} else if device.DeviceType == "web" {
🌐
@Icon("globe", "h-5 w-5")
} else {
📱
@Icon("device", "h-5 w-5")
}
</div>
<div class="flex space-x-2">
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
📚
</button>
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
⚙️
</span>
<div class="flex space-x-1">
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="icon-btn" title="Shelf mappings" aria-label="Shelf mappings">
@Icon("layers", "h-5 w-5")
</button>
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="icon-btn" title="Device settings" aria-label="Device settings">
@Icon("gear", "h-5 w-5")
</button>
</div>
</div>
<h3 class="text-lg font-semibold mb-1" style="color: var(--text-primary)">{ device.DeviceName }</h3>
@@ -72,9 +74,9 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
<div class="flex justify-between">
<span style="color: var(--text-secondary)">Sync Status</span>
if device.SyncEnabled {
<span style="color: var(--accent)"> Enabled</span>
<span class="badge status-completed">Enabled</span>
} else {
<span style="color: var(--text-secondary)"> Disabled</span>
<span class="badge" style="background-color: var(--surface-hover); color: var(--text-secondary);">Disabled</span>
}
</div>
<div class="flex justify-between">
@@ -82,7 +84,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
if device.LastSync != nil {
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span>
} else {
<span style="color: var(--text-primary)">Never</span>
<span style="color: var(--text-secondary)">Never</span>
}
</div>
<div class="flex justify-between">
@@ -90,15 +92,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
if device.LastSeen != nil {
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span>
} else {
<span style="color: var(--text-primary)">Never</span>
<span style="color: var(--text-secondary)">Never</span>
}
</div>
</div>
<!-- NEW: Sync URL & Token Management -->
<div class="border-t pt-4" style="border-color: var(--border);">
<p class="text-xs font-semibold mb-2" style="color: var(--text-secondary)">DEVICE SYNC CONFIGURATION</p>
<p class="text-xs font-semibold uppercase tracking-wide mb-3" style="color: var(--text-secondary)">Device Sync Configuration</p>
if device.DeviceType == "kobo" {
<!-- Kobo: Copy Full Sync URL -->
<div class="mb-3">
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label>
<div class="flex space-x-2">
@@ -107,22 +107,20 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
id="sync-url-{ device.ID }"
readonly
value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
class="flex-1 px-3 py-2 text-xs rounded border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input text-xs font-mono"
/>
<button
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
class="px-3 py-2 text-xs rounded hover:opacity-80"
style="background-color: var(--accent); color: white;"
class="btn btn-primary text-xs px-3"
>
📋 Copy
@Icon("copy", "h-4 w-4")
Copy
</button>
</div>
<p class="text-xs mt-1" style="color: var(--text-secondary);">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">api_endpoint</code> setting</p>
</div>
}
if device.DeviceType == "koreader" {
<!-- KOReader: Copy Auth Token -->
<div class="mb-3">
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label>
<div class="flex space-x-2">
@@ -131,43 +129,40 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
id="auth-token-{ device.ID }"
readonly
value={ device.AuthToken }
class="flex-1 px-3 py-2 text-xs rounded border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border); font-family: monospace;"
class="input text-xs font-mono"
/>
<button
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
class="px-3 py-2 text-xs rounded hover:opacity-80"
style="background-color: var(--accent); color: white;"
class="btn btn-primary text-xs px-3"
>
📋 Copy
@Icon("copy", "h-4 w-4")
Copy
</button>
</div>
<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p>
</div>
}
<!-- Regenerate Token Button -->
<button
hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
hx-confirm="⚠️ Old token will immediately stop working. Regenerate anyway?"
class="w-full px-3 py-2 text-xs rounded border hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary); background-color: var(--bg-primary);"
hx-confirm="Old token will immediately stop working. Regenerate anyway?"
class="btn btn-secondary w-full text-xs"
>
🔄 Regenerate Token
@Icon("refresh", "h-4 w-4")
Regenerate Token
</button>
<p class="text-xs mt-1" style="color: var(--text-secondary);">⚠️ Old token will immediately stop working</p>
<p class="text-xs mt-1" style="color: var(--status-warning);">Old token will immediately stop working</p>
</div>
</div>
}
</div>
}
</div>
<!-- Pending Registrations -->
if len(pendingRegistrations) > 0 {
<div id="pending-section" class="mt-12">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2>
<h2 class="text-lg font-bold tracking-tight mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2>
<div class="space-y-4">
for _, reg := range pendingRegistrations {
<div class="card p-4 rounded-lg border flex items-center justify-between" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 class="font-semibold" style="color: var(--text-primary)">{ reg.DeviceName }</h3>
<p class="text-sm" style="color: var(--text-secondary)">
@@ -175,10 +170,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</p>
</div>
<div class="flex space-x-2">
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn-primary px-4 py-2 rounded-lg text-sm">
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn btn-primary text-sm">
Approve
</button>
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn-danger px-4 py-2 rounded-lg text-sm">
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn btn-danger text-sm">
Reject
</button>
</div>
@@ -187,92 +182,134 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</div>
</div>
}
<!-- Sync Queue Section -->
<div id="sync-queue-section" class="mt-12 hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Queue</h2>
<button @click="clearSyncQueue()" class="btn-secondary px-4 py-2 rounded-lg">
Clear Queue
</button>
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Sync Queue</h2>
<button @click="clearSyncQueue()" class="btn btn-secondary">Clear Queue</button>
</div>
<div id="sync-queue" class="space-y-3"></div>
</div>
</div>
<!-- Add Device Modal -->
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2>
<button @click="hideAddDeviceModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" x-data="{ selectedType: '' }" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2>
<button @click="hideAddDeviceModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Type</label>
<select
id="device-type"
x-model="selectedType"
required
class="input"
>
<option value="">Select device type...</option>
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
<option value="kobo">Kobo E-Reader</option>
<!-- <option value="web">Web Browser</option> -->
<!-- <option value="mobile">Mobile App</option> -->
</select>
</div>
<div x-show="selectedType === 'koreader'" x-cloak class="space-y-4">
<div class="rounded-lg p-4 space-y-4" style="background-color: var(--surface-secondary);">
<h3 class="text-sm font-semibold flex items-center gap-2" style="color: var(--text-primary)">
@Icon("book-open", "h-4 w-4")
KOReader Plugin Setup
</h3>
<ol class="space-y-3 text-sm" style="color: var(--text-secondary)">
<li class="flex gap-3">
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">1</span>
<span>Clone the <a href="https://git.linuxhg.com/Bookhoard/bookhoard.koplugin" class="underline" style="color: var(--accent);">Bookhoard plugin</a> to your KOReader <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">plugins/</code> directory</span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">2</span>
<span>Open KOReader, tap the <strong>wrench icon</strong> at the top</span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">3</span>
<span>Find and tap <strong>Bookhoard sync</strong></span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">4</span>
<span>Tap <strong>Server URL</strong>, enter your server address, then tap <strong>OK</strong>:</span>
</li>
<li class="ml-8">
<div class="flex items-center gap-2">
<code class="text-xs px-2 py-1 rounded font-mono flex-1" style="background-color: var(--bg-primary); color: var(--text-primary);">{ baseURL }</code>
<button
@click={ "copyToClipboard('" + baseURL + "', 'Server URL')" }
class="btn btn-secondary text-xs px-3"
>
@Icon("copy", "h-4 w-4")
</button>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">5</span>
<span>Return to this page and refresh you'll see a <strong>pending registration</strong>. Click <strong>Approve</strong> to connect the device.</span>
</li>
</ol>
<div class="rounded-md p-3 text-xs flex items-start gap-2" style="background-color: var(--accent-muted); color: var(--text-secondary);">
@Icon("info", "h-4 w-4 flex-shrink-0 mt-0.5")
<span>Once approved, reading progress sync and OPDS catalog access are set up automatically.</span>
</div>
</div>
<div class="flex justify-end">
<button type="button" @click="hideAddDeviceModal()" class="btn btn-primary">Got it</button>
</div>
</div>
<div x-show="selectedType === 'kobo'" x-cloak>
<form id="add-device-form" @submit="handleAddDevice($event)">
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
<input
type="text"
id="device-name"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="My Kindle Paperwhite"
class="input"
placeholder="My Kobo"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Type</label>
<select
id="device-type"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
>
<option value="">Select device type...</option>
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
<option value="kobo">Kobo E-Reader</option>
<option value="web">Web Browser</option>
<option value="mobile">Mobile App</option>
</select>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Identifier</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Identifier</label>
<input
type="text"
id="device-identifier"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="Hardware ID or Serial Number"
/>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Enter your device's unique identifier</p>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="hideAddDeviceModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
Register Device
</button>
<button type="button" @click="hideAddDeviceModal()" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Register Device</button>
</div>
</form>
</div>
</div>
<!-- Device Settings Modal -->
<div id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
</div>
<div id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Device Settings</h2>
<button @click="hideDeviceSettingsModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<button @click="hideDeviceSettingsModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<form id="device-settings-form" @submit="handleSaveDeviceSettings($event)">
<input type="hidden" id="settings-device-id"/>
<input type="hidden" id="settings-device-type"/>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
<input
type="text"
id="settings-device-name"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div class="mb-4">
@@ -300,11 +337,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Automatically sync changes</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Frequency</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Frequency</label>
<select
id="settings-sync-frequency"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="1">Every 1 minute</option>
<option value="5" selected>Every 5 minutes</option>
@@ -314,14 +350,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</select>
</div>
<div class="border-t pt-6 mb-6" style="border-color: var(--border);">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Collection View Settings</h3>
<h3 class="text-base font-semibold mb-1" style="color: var(--text-primary)">Collection View Settings</h3>
<p class="text-sm mb-4" style="color: var(--text-secondary)">Configure how collections are displayed on this device</p>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Default View Mode</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default View Mode</label>
<select
id="settings-view-mode"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="grid">Grid View</option>
<option value="list">List View</option>
@@ -329,11 +364,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sort Collections By</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sort Collections By</label>
<select
id="settings-sort-order"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="name">Collection Name</option>
<option value="created">Date Created</option>
@@ -342,11 +376,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Items Per Page</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Items Per Page</label>
<select
id="settings-items-per-page"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="12">12 items</option>
<option value="24" selected>24 items</option>
@@ -378,81 +411,73 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Display progress indicators for books</p>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="hideDeviceSettingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
</button>
<button type="button" @click="handleRevokeDevice()" class="btn-danger px-4 py-2 rounded-lg">
Revoke Device
</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
Save Settings
</button>
<div class="flex justify-end space-x-3 flex-wrap">
<button type="button" @click="hideDeviceSettingsModal()" class="btn btn-secondary">Cancel</button>
<button type="button" @click="handleRevokeDevice()" class="btn btn-danger">Revoke Device</button>
<button type="submit" class="btn btn-primary">Save Settings</button>
</div>
</form>
</div>
</div>
<!-- Device Shelf Mappings Modal -->
<div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Collection to Shelf Mappings</h2>
<button @click="hideShelfMappingsModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<button @click="hideShelfMappingsModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<input type="hidden" id="mappings-device-id"/>
<div id="shelf-mappings-container" class="space-y-4 mb-6">
<p style="color: var(--text-secondary)">Loading mappings...</p>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="hideShelfMappingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
Close
</button>
<button type="button" @click="showAddMappingModal()" class="btn-primary px-4 py-2 rounded-lg">
Add Mapping
<button type="button" @click="hideShelfMappingsModal()" class="btn btn-secondary">Close</button>
<button type="button" @click="showAddMappingModal()" class="btn btn-primary">
@Icon("plus", "h-4 w-4")
Add Mapping
</button>
</div>
</div>
</div>
<!-- Add/Edit Shelf Mapping Modal -->
<div id="add-mapping-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div id="add-mapping-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Collection Mapping</h2>
<button @click="hideAddMappingModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<button @click="hideAddMappingModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<form id="mapping-form" @submit="handleSaveMapping($event)">
<input type="hidden" id="mapping-device-id"/>
<input type="hidden" id="mapping-id"/>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Collection</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Collection</label>
<select
id="mapping-collection"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="">Select collection...</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Shelf Name</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Shelf Name</label>
<input
type="text"
id="mapping-shelf-name"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
placeholder="e.g., Sci-Fi"
/>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Name of the shelf on this device</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Direction</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Direction</label>
<select
id="mapping-sync-direction"
required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="bidirectional">Bidirectional (sync both ways)</option>
<option value="book_to_device">Bookhoard Device</option>
@@ -461,12 +486,8 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</select>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="hideAddMappingModal()" class="btn-secondary px-4 py-2 rounded-lg">
Cancel
</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
Save Mapping
</button>
<button type="button" @click="hideAddMappingModal()" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Save Mapping</button>
</div>
</form>
</div>
File diff suppressed because one or more lines are too long
+84 -83
View File
@@ -12,101 +12,102 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<link href="/static/style.css" rel="stylesheet"/>
<link rel="stylesheet" href="/static/highlight-dark.min.css"/>
</head>
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased" }>
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
@Header(user, currentPath)
<!-- Mobile Menu Button -->
<button
class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80"
class="lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised"
onclick="toggleSidebar()"
aria-label="Toggle menu"
aria-label="Toggle documentation menu"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
@Icon("menu", "h-5 w-5")
</button>
<!-- Search Results Overlay -->
<div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out"></div>
<!-- Sidebar -->
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out">
<!-- Search -->
<div class="p-4 border-b border-border">
<input
type="text"
class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm"
placeholder="Search documentation..."
id="docs-search"
/>
</div>
<!-- Navigation Sections -->
for _, section := range nav.Sections {
<div class="nav-section mb-6">
<div
class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between"
@click="toggleSection($el)"
>
<span>{ section.Title }</span>
<svg class="w-4 h-4 transform transition-transform section-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="toggleSidebar()"></div>
<div id="search-results" class="hidden fixed inset-0 z-50 overflow-y-auto p-8" style="background: var(--surface-overlay);"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex gap-8 py-8">
<aside id="docs-sidebar" class="sidebar fixed lg:sticky top-16 lg:top-20 bottom-0 lg:bottom-auto left-0 lg:left-auto z-40 lg:z-10 w-64 shrink-0 lg:h-[calc(100vh-6rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line lg:border-r-0">
<div class="p-4 border-b border-line">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
@Icon("search", "h-4 w-4")
</span>
<input
type="text"
class="search-input input pl-9"
placeholder="Search documentation…"
id="docs-search"
/>
</div>
</div>
if section.Collapsed {
<div class="nav-items hidden" data-collapsed="true">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
<span class="mr-2">{ item.Icon }</span>{ item.Title }
</a>
}
</div>
} else {
<div class="nav-items" data-collapsed="false">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
<span class="mr-2">{ item.Icon }</span>{ item.Title }
</a>
for _, section := range nav.Sections {
<div class="nav-section border-b border-line">
<div
class="nav-section-title flex items-center justify-between px-4 py-3 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover"
style="color: var(--text-secondary);"
@click="toggleSection($el)"
>
<span>{ section.Title }</span>
@Icon("chevron-down", "h-4 w-4 section-arrow transition-transform")
</div>
if section.Collapsed {
<div class="nav-items hidden" data-collapsed="true">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
<span>{ item.Title }</span>
</a>
}
</div>
} else {
<div class="nav-items" data-collapsed="false">
for _, item := range section.Items {
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
<span>{ item.Title }</span>
</a>
}
</div>
}
</div>
}
</div>
}
</div>
<!-- Main Content -->
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
<!-- Breadcrumb -->
if len(doc.Breadcrumb) > 0 {
<nav class="breadcrumb flex gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
for i, crumb := range doc.Breadcrumb {
if i > 0 {
<span class="text-text-secondary/50"></span>
}
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
</aside>
<main class="main-content flex-1 min-w-0 max-w-4xl">
if len(doc.Breadcrumb) > 0 {
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8" aria-label="Breadcrumb" style="color: var(--text-secondary);">
for i, crumb := range doc.Breadcrumb {
if i > 0 {
@Icon("chevron-right", "h-4 w-4")
}
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a>
}
</nav>
}
</nav>
}
<!-- Title -->
<h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1>
<!-- Table of Contents -->
if len(doc.TOC) > 0 {
<details class="toc bg-background-secondary p-4 rounded-lg mb-8 border border-border">
<summary class="cursor-pointer hover:opacity-80 transition-opacity">
<strong class="text-text-primary font-semibold">On this page</strong>
<span class="ml-2 text-text-secondary text-xs"></span>
</summary>
for _, item := range doc.TOC {
<a
href={ "#" + item.Anchor }
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
>
{ item.Title }
</a>
<h1 class="text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
if len(doc.TOC) > 0 {
<details class="toc card p-4 mb-8">
<summary class="cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl" style="color: var(--text-primary);">
<span>On this page</span>
@Icon("chevron-down", "h-4 w-4 ml-auto")
</summary>
<div class="mt-3">
for _, item := range doc.TOC {
<a
href={ "#" + item.Anchor }
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand"
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);" }
>
{ item.Title }
</a>
}
</div>
</details>
}
</details>
}
<!-- Content -->
<div class="prose prose-invert max-w-none">
@UnsafeHTML(doc.Content).ToComponent()
<div class="prose prose-invert max-w-none">
@UnsafeHTML(doc.Content).ToComponent()
</div>
</main>
</div>
</main>
</div>
</body>
</html>
}
+80 -48
View File
@@ -48,7 +48,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased"}
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs font-sans antialiased"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -74,267 +74,299 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<!-- Mobile Menu Button --><button class=\"lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80\" onclick=\"toggleSidebar()\" aria-label=\"Toggle menu\"><svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 12h16M4 18h16\"></path></svg></button><!-- Search Results Overlay --><div id=\"search-results\" class=\"hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out\"></div><!-- Sidebar --><div class=\"sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out\"><!-- Search --><div class=\"p-4 border-b border-border\"><input type=\"text\" class=\"search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm\" placeholder=\"Search documentation...\" id=\"docs-search\"></div><!-- Navigation Sections -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<button class=\"lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised\" onclick=\"toggleSidebar()\" aria-label=\"Toggle documentation menu\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("menu", "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, 6, "</button><div id=\"docs-overlay\" class=\"hidden fixed inset-0 z-40 lg:hidden\" style=\"background: var(--surface-overlay);\" onclick=\"toggleSidebar()\"></div><div id=\"search-results\" class=\"hidden fixed inset-0 z-50 overflow-y-auto p-8\" style=\"background: var(--surface-overlay);\"></div><div class=\"mx-auto max-w-7xl px-4 sm:px-6 lg:px-8\"><div class=\"flex gap-8 py-8\"><aside id=\"docs-sidebar\" class=\"sidebar fixed lg:sticky top-16 lg:top-20 bottom-0 lg:bottom-auto left-0 lg:left-auto z-40 lg:z-10 w-64 shrink-0 lg:h-[calc(100vh-6rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line lg:border-r-0\"><div class=\"p-4 border-b border-line\"><div class=\"relative\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> <input type=\"text\" class=\"search-input input pl-9\" placeholder=\"Search documentation…\" id=\"docs-search\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, section := range nav.Sections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"nav-section mb-6\"><div class=\"nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between\" @click=\"toggleSection($el)\"><span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"nav-section border-b border-line\"><div class=\"nav-section-title flex items-center justify-between px-4 py-3 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleSection($el)\"><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 47, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 49, Col: 30}
}
_, 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, "</span> <svg class=\"w-4 h-4 transform transition-transform section-arrow\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path></svg></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4 section-arrow transition-transform").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.Collapsed {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range section.Items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 55, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 55, Col: 29}
}
_, 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, 10, "\" class=\"nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline\"><span class=\"mr-2\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\"><span class=\"text-xs\" style=\"color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 75}
}
_, 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, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 30}
}
_, 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, 12, "</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"nav-items\" data-collapsed=\"false\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"nav-items\" data-collapsed=\"false\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range section.Items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 63, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 29}
}
_, 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, 16, "\" class=\"nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline\"><span class=\"mr-2\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\"><span class=\"text-xs\" style=\"color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 75}
}
_, 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, 17, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> <span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 30}
}
_, 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, 18, "</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><!-- Main Content --><main class=\"main-content lg:ml-72 p-8 max-w-4xl mx-auto\"><!-- Breadcrumb -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside><main class=\"main-content flex-1 min-w-0 max-w-4xl\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(doc.Breadcrumb) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<nav class=\"breadcrumb flex gap-2 text-sm text-text-secondary mb-8\" aria-label=\"Breadcrumb\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<nav class=\"breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8\" aria-label=\"Breadcrumb\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i, crumb := range doc.Breadcrumb {
if i > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"text-text-secondary/50\"></span>")
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " <a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 28}
}
_, 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, 25, "\" class=\"text-accent hover:underline no-underline\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"no-underline transition-colors hover:underline\" style=\"color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 129}
}
_, 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, 26, "</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</nav>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<!-- Title --><h1 class=\"text-4xl font-bold mb-8 text-text-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<h1 class=\"text-4xl font-bold mb-8 tracking-tight\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 85, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h1><!-- Table of Contents -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(doc.TOC) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<details class=\"toc bg-background-secondary p-4 rounded-lg mb-8 border border-border\"><summary class=\"cursor-pointer hover:opacity-80 transition-opacity\"><strong class=\"text-text-primary font-semibold\">On this page</strong> <span class=\"ml-2 text-text-secondary text-xs\">▼</span></summary> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<details class=\"toc card p-4 mb-8\"><summary class=\"cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl\" style=\"color: var(--text-primary);\"><span>On this page</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4 ml-auto").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</summary><div class=\"mt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range doc.TOC {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 95, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline\" style=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"toc-item block py-1 text-sm no-underline transition-colors hover:text-brand\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level))
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 97, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 20}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 99, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</details>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<!-- Content --><div class=\"prose prose-invert max-w-none\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"prose prose-invert max-w-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -342,7 +374,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div></main></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+5 -6
View File
@@ -1,16 +1,14 @@
package templates
// FilterItem renders a single saved filter item for the list
templ FilterItem(id string, name string) {
<div
class="flex items-center justify-between p-2 rounded hover:opacity-80"
style="background-color: var(--bg-primary);"
class="flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover"
data-filter-id={ id }
>
<button
data-action="load-filter"
@click="loadFilter($event)"
class="flex-1 text-left px-2 py-1 rounded"
class="flex-1 text-left px-2 py-1 text-sm rounded-lg hover:bg-surface-hover"
style="color: var(--text-primary);"
>
{ name }
@@ -18,11 +16,12 @@ templ FilterItem(id string, name string) {
<button
data-action="delete-filter"
@click="deleteFilter($event)"
class="p-1 hover:opacity-70 rounded"
class="icon-btn shrink-0"
style="color: var(--text-secondary);"
title="Delete filter"
aria-label="Delete filter"
>
🗑
@Icon("trash", "h-4 w-4")
</button>
</div>
}
+13 -6
View File
@@ -8,7 +8,6 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// FilterItem renders a single saved filter item for the list
func FilterItem(id string, name 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
@@ -30,33 +29,41 @@ func FilterItem(id string, name string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between p-2 rounded hover:opacity-80\" style=\"background-color: var(--bg-primary);\" data-filter-id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover\" data-filter-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 8, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 6, Col: 21}
}
_, 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, 2, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 rounded\" style=\"color: var(--text-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 text-sm rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 16, Col: 9}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 14, Col: 9}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"p-1 hover:opacity-70 rounded\" style=\"color: var(--text-secondary);\" title=\"Delete filter\">🗑</button></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn shrink-0\" style=\"color: var(--text-secondary);\" title=\"Delete filter\" aria-label=\"Delete filter\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+209 -512
View File
@@ -1,531 +1,228 @@
package templates
templ Header(user User, currentPath string) {
<nav x-data="header" x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()" class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);">
<div x-data="{}" x-init="console.log('Alpine is working!', $el)"></div>
<div class="w-full px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Left: App Title & Navigation -->
<div class="flex items-center space-x-6">
<a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity flex items-center gap-2" style="color: var(--text-primary); text-decoration: none;">
<span>📚 Bookhoard</span>
<!-- Scan spinner -->
<svg
x-show="scanning"
x-transition
class="h-5 w-5 animate-spin"
style="color: var(--accent);"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-show="scanning && scanProgress > 0" x-transition class="text-xs font-normal" style="color: var(--text-secondary);" x-text="scanProgress + '%'"></span>
</a>
<div class="hidden nav:flex items-center space-x-4">
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
<a
href="/bookshelf"
class="text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary); text-decoration: none;"
>
All Books
</a>
<a href="/series" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Series
</a>
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections
</a>
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress
</a>
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices
</a>
</div>
</div>
<!-- Center: Search Box -->
<div class="flex-1 max-w-2xl mx-8 hidden nav:block">
<div class="relative">
<input
type="text"
id="header-search"
placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
<!-- Right: Theme Switcher & User Menu -->
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="hidden nav:flex items-center space-x-4">
<!-- Theme Switcher -->
<div class="relative">
<button
@click="themeDropdownOpen = !themeDropdownOpen"
type="button"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);"
>
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
</button>
<div
x-show="themeDropdownOpen"
@click.outside="themeDropdownOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
>
<div class="p-4">
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
<div class="space-y-2">
<button
@click="changeTheme('tokyo-night'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night
</button>
<button
@click="changeTheme('dracula'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula
</button>
<button
@click="changeTheme('nord'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord
</button>
<button
@click="changeTheme('solarized-dark'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark
</button>
<button
@click="changeTheme('monokai'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai
</button>
<button
@click="changeTheme('one-dark-pro'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro
</button>
<button
@click="changeTheme('material-dark'); themeDropdownOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark
</button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
<button
@click="changeWoodPaneling('none'); themeDropdownOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none"
>
None
</button>
<button
@click="changeWoodPaneling('wood-light'); themeDropdownOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-light"
>
<span
class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-light.png'); background-size: cover;"
></span>
Wood Light
</button>
<button
@click="changeWoodPaneling('wood-dark'); themeDropdownOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-dark"
>
<span
class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"
></span>
Wood Dark
</button>
<button
@click="changeWoodPaneling('wood-mahogany'); themeDropdownOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-mahogany"
>
<span
class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"
></span>
Wood Mahogany
</button>
</div>
</div>
</div>
</div>
</div>
<!-- User Icon with Dropdown -->
<div class="relative">
if user.ID != "" {
<!-- LOGGED IN: Show user menu with logout -->
<div
x-data="header"
x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()"
>
<!-- Mobile backdrop -->
<div
class="app-sidebar-backdrop lg:hidden"
:class="{ 'is-open': mobileMenuOpen }"
@click="mobileMenuOpen = false"
x-cloak
></div>
<!-- Sidebar -->
<aside class="app-sidebar" :class="{ 'is-open': mobileMenuOpen }">
<!-- Logo -->
<a
href="/dashboard"
class="flex items-center gap-2.5 px-5 h-16 shrink-0 border-b"
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
>
<span class="text-xl">📚</span>
<span class="font-bold text-lg tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
<svg
x-show="scanning"
x-cloak
x-transition
class="h-4 w-4 animate-spin ml-auto"
style="color: var(--accent);"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</a>
<span
x-show="scanning && scanProgress > 0"
x-cloak
x-transition
class="block px-5 py-1.5 text-xs font-medium -mt-1"
style="color: var(--text-secondary);"
x-text="'Scanning… ' + scanProgress + '%'"
></span>
<!-- Primary navigation -->
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
<a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }>
@Icon("library", "h-5 w-5 shrink-0")
<span>Library</span>
</a>
<a href="/bookshelf" class={ activeClass(currentPath, "/bookshelf") }>
@Icon("book", "h-5 w-5 shrink-0")
<span>All Books</span>
</a>
<a href="/series" class={ activeClass(currentPath, "/series") }>
@Icon("layers", "h-5 w-5 shrink-0")
<span>Series</span>
</a>
<a href="/collections" class={ activeClass(currentPath, "/collections") }>
@Icon("folder", "h-5 w-5 shrink-0")
<span>Collections</span>
</a>
<a href="/progress" class={ activeClass(currentPath, "/progress") }>
@Icon("clock", "h-5 w-5 shrink-0")
<span>Progress</span>
</a>
<a href="/devices" class={ activeClass(currentPath, "/devices") }>
@Icon("device", "h-5 w-5 shrink-0")
<span>Devices</span>
</a>
</nav>
<!-- Bottom: theme picker + user -->
<div
class="shrink-0 px-3 py-3 space-y-2 border-t"
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
>
if user.ID != "" {
@SidebarUserMenu(user)
} else {
@SidebarSignIn(currentPath)
}
<!-- Theme picker -->
<div x-data="{ themeOpen: false }" class="sidebar-panel">
<button
type="button"
@click="themeOpen = !themeOpen"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
>
@Icon("palette", "h-5 w-5 shrink-0")
<span>Appearance</span>
@Icon("chevron-down", "h-4 w-4 ml-auto transition-transform")
</button>
<div x-show="themeOpen" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
for _, opt := range ThemeOptions {
<button
@click="userMenuOpen = !userMenuOpen"
type="button"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);"
@click={ "changeTheme('" + opt.Name + "'); themeOpen = false" }
class="w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
style="color: var(--text-secondary);"
>
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-color: " + opt.Color }></span>
<span class="flex-1 text-left">{ opt.Label }</span>
if user.Theme == opt.Name {
@Icon("check", "h-4 w-4 shrink-0")
}
</button>
<div
x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
>
<div class="py-1">
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Admin Panel
</a>
}
<div class="border-t my-1" style="border-color: var(--border);"></div>
<button
@click="logout(); userMenuOpen = false"
type="button"
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
style="color: var(--text-primary); background-color: var(--bg-secondary);"
>
Logout
</button>
</div>
</div>
} else {
<!-- LOGGED OUT: Show login form -->
}
<div class="my-1.5 mx-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"></div>
<p class="px-3 pb-1 text-xs font-medium uppercase tracking-wide" style="color: var(--text-secondary);">Bookshelf</p>
for _, w := range WoodOptions {
<button
@click="userMenuOpen = !userMenuOpen"
type="button"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);"
@click={ "changeWoodPaneling('" + w.Name + "')" }
class="wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
data-wood={ w.Name }
style="color: var(--text-secondary);"
>
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style="background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);"></span>
<span class="flex-1 text-left">{ w.Label }</span>
</button>
<div
x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
>
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"/>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In
</button>
</form>
<div id="login-result"></div>
<div class="border-t my-2" style="border-color: var(--border);"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up
</a>
</div>
}
</div>
</div>
<button
@click="mobileMenuOpen = !mobileMenuOpen"
type="button"
class="nav:hidden p-2 rounded-lg hover:opacity-80 transition-opacity"
style="background-color: var(--bg-primary); color: var(--text-primary);"
aria-label="Toggle menu"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
<div
x-show="mobileMenuOpen"
@click.outside="mobileMenuOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="nav:hidden border-t"
style="border-color: var(--border); background-color: var(--bg-secondary); display: none;"
x-data="{ mobileThemeOpen: false }"
>
<div class="px-4 py-3 space-y-1">
<div class="relative mb-3">
<input
type="text"
id="header-search-mobile"
placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<a href="/dashboard" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
<a href="/bookshelf" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
All Books
</a>
<a href="/series" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Series
</a>
<a href="/collections" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections
</a>
<a href="/progress" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress
</a>
<a href="/devices" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices
</a>
<div class="border-t my-2" style="border-color: var(--border);"></div>
<button
@click="mobileThemeOpen = !mobileThemeOpen"
type="button"
class="flex items-center space-x-2 w-full px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary);"
>
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
<span>Theme</span>
<svg class="h-4 w-4 ml-auto transform transition-transform" :class="{ 'rotate-180': mobileThemeOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="mobileThemeOpen" x-transition class="pl-8 pr-2 py-1 space-y-1">
<button
@click="changeTheme('tokyo-night'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night
</button>
<button
@click="changeTheme('dracula'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula
</button>
<button
@click="changeTheme('nord'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord
</button>
<button
@click="changeTheme('solarized-dark'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark
</button>
<button
@click="changeTheme('monokai'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai
</button>
<button
@click="changeTheme('one-dark-pro'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro
</button>
<button
@click="changeTheme('material-dark'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark
</button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2 px-3" style="color: var(--text-secondary)">Bookshelf Background</p>
<button
@click="changeWoodPaneling('none'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none"
>
None
</button>
<button
@click="changeWoodPaneling('wood-light'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-light"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
Wood Light
</button>
<button
@click="changeWoodPaneling('wood-dark'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-dark"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
Wood Dark
</button>
<button
@click="changeWoodPaneling('wood-mahogany'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-mahogany"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
Wood Mahogany
</button>
</div>
</div>
<div class="border-t my-2" style="border-color: var(--border);"></div>
if user.ID != "" {
<div class="space-y-1">
<div class="flex items-center space-x-2 px-3 py-2">
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm" style="color: var(--text-primary)">{ user.Username }</span>
</div>
<a href="/profile" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Admin Panel
</a>
}
<button
@click="logout()"
type="button"
class="block w-full text-left px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary);"
>
Logout
</button>
</div>
} else {
<div class="space-y-3">
<form hx-post="/api/auth/login" hx-target="#login-result-mobile" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"/>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In
</button>
</form>
<div id="login-result-mobile"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up
</a>
</div>
}
</aside>
<!-- Topbar -->
<header class="app-topbar">
<div class="flex items-center gap-3 px-4 sm:px-6 h-full">
<button
type="button"
class="app-mobile-only icon-btn"
@click="mobileMenuOpen = true"
aria-label="Open menu"
>
@Icon("menu", "h-5 w-5")
</button>
<div class="relative flex-1 max-w-xl">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
@Icon("search", "h-4 w-4")
</span>
<input
type="text"
id="header-search"
placeholder="Search your library…"
class="input pl-10"
autocomplete="off"
/>
</div>
</div>
</header>
<script type="module" src="/static/main.js"></script>
</div>
}
// SidebarUserMenu is the bottom-of-sidebar account control for signed-in users.
templ SidebarUserMenu(user User) {
<div x-data="{ userOpen: false }" class="sidebar-panel">
<button
type="button"
@click="userOpen = !userOpen"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover"
style="color: var(--text-primary);"
>
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("user", "h-4 w-4")
</span>
<span class="flex-1 text-left truncate">{ user.Username }</span>
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform")
</button>
<div x-show="userOpen" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
<a href="/profile" class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
@Icon("user", "h-4 w-4")
<span>Profile</span>
</a>
if user.Role == "admin" {
<a href="/admin" class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
@Icon("settings", "h-4 w-4")
<span>Admin Panel</span>
</a>
}
<button
type="button"
@click="logout()"
class="w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
style="color: var(--text-secondary);"
>
@Icon("logout", "h-4 w-4")
<span>Logout</span>
</button>
</div>
</nav>
<script type="module" src="/static/main.js"></script>
</div>
}
// SidebarSignIn is an inline sign-in for signed-out visitors, preserving the
// original header's inline login (htmx POST) so users can auth from any page.
templ SidebarSignIn(currentPath string) {
<div x-data="{ signInOpen: false }" class="sidebar-panel">
<button
type="button"
@click="signInOpen = !signInOpen"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover"
style="color: var(--text-primary);"
>
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("user", "h-4 w-4")
</span>
<span class="flex-1 text-left">Sign in</span>
</button>
<div x-show="signInOpen" x-cloak x-transition class="mt-1 px-1">
<form
hx-post="/api/auth/login"
hx-target="#login-result"
hx-swap="innerHTML"
class="space-y-2"
>
<input type="hidden" name="redirect" value={ currentPath }/>
<input type="text" name="login" class="input py-1.5 text-sm" placeholder="Email or username" required/>
<input type="password" name="password" class="input py-1.5 text-sm" placeholder="Password" required/>
<button type="submit" class="btn btn-primary w-full py-1.5 text-sm">Sign In</button>
</form>
<div id="login-result"></div>
<a href="/register" class="block text-center text-xs mt-2 hover:underline" style="color: var(--text-secondary);">
Create an account
</a>
</div>
</div>
}
+447 -43
View File
File diff suppressed because one or more lines are too long
+175
View File
@@ -0,0 +1,175 @@
package templates
// Icon renders an inline SVG from the built-in icon set. All icons share the
// same 24x24 viewBox, 1.75 stroke width, and round line caps so the UI has one
// consistent visual language (replacing the mixed emoji/SVG iconography).
// `extra` is appended to the class list (e.g. "h-5 w-5").
templ Icon(name string, extra string) {
<svg class={ "reader-icon " + extra } width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
switch name {
case "search":
<circle cx="11" cy="11" r="7"></circle>
<path d="m20 20-3.5-3.5"></path>
case "user":
<circle cx="12" cy="8" r="4"></circle>
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"></path>
case "users":
<circle cx="9" cy="8" r="3.5"></circle>
<path d="M2 20c0-3.5 3-5 7-5s7 1.5 7 5"></path>
<path d="M16 4.5a3.5 3.5 0 0 1 0 7"></path>
<path d="M22 20c0-3-2-4.5-5-4.5"></path>
case "palette":
<circle cx="12" cy="12" r="9"></circle>
<circle cx="8" cy="10" r="1"></circle>
<circle cx="12" cy="7.5" r="1"></circle>
<circle cx="15.5" cy="10" r="1"></circle>
<path d="M12 21a3 3 0 0 0 0-6 2 2 0 0 1 0-4h1"></path>
case "menu":
<path d="M3 6h18M3 12h18M3 18h18"></path>
case "chevron-down":
<path d="m6 9 6 6 6-6"></path>
case "chevron-up":
<path d="m6 15 6-6 6 6"></path>
case "chevron-left":
<path d="m15 6-6 6 6 6"></path>
case "chevron-right":
<path d="m9 6 6 6-6 6"></path>
case "chevrons-left":
<path d="m11 6-6 6 6 6M19 6l-6 6 6 6"></path>
case "chevrons-right":
<path d="m13 6 6 6-6 6M5 6l6 6-6 6"></path>
case "close":
<path d="M6 6l12 12M18 6 6 18"></path>
case "grip":
<circle cx="9" cy="6" r="1"></circle>
<circle cx="15" cy="6" r="1"></circle>
<circle cx="9" cy="12" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="9" cy="18" r="1"></circle>
<circle cx="15" cy="18" r="1"></circle>
case "settings":
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"></path>
case "gear":
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
case "refresh":
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M21 3v5h-5"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
<path d="M3 21v-5h5"></path>
case "book":
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
case "book-open":
<path d="M12 7v14"></path>
<path d="M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z"></path>
<path d="M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z"></path>
case "library":
<path d="M3 21h18"></path>
<path d="M5 21V7l7-4 7 4v14"></path>
case "layers":
<path d="m12 3 9 5-9 5-9-5 9-5z"></path>
<path d="m3 13 9 5 9-5"></path>
case "folder":
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
case "chart":
<path d="M3 3v18h18"></path>
<path d="M7 15v3M12 11v7M17 7v11"></path>
case "device":
<rect x="7" y="2" width="10" height="20" rx="2"></rect>
<path d="M11 18h2"></path>
case "trash":
<path d="M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13"></path>
case "save":
<path d="M5 3h11l3 3v15H5z"></path>
<path d="M8 3v6h7V3"></path>
<path d="M8 14h8v7H8z"></path>
case "filter":
<path d="M3 4h18l-7 8v6l-4 2v-8z"></path>
case "check":
<path d="m4 12 5 5 11-11"></path>
case "check-circle":
<circle cx="12" cy="12" r="9"></circle>
<path d="m8 12 3 3 5-6"></path>
case "x-circle":
<circle cx="12" cy="12" r="9"></circle>
<path d="m9 9 6 6M15 9l-6 6"></path>
case "circle":
<circle cx="12" cy="12" r="8"></circle>
case "bookmark":
<path d="M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z"></path>
case "star":
<path d="m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z"></path>
case "arrow-right":
<path d="M5 12h14M13 6l6 6-6 6"></path>
case "arrow-left":
<path d="M19 12H5M11 6l-6 6 6 6"></path>
case "home":
<path d="M3 11 12 3l9 8"></path>
<path d="M5 10v10h14V10"></path>
case "logout":
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"></path>
<path d="M10 17l5-5-5-5"></path>
<path d="M15 12H3"></path>
case "plus":
<path d="M12 5v14M5 12h14"></path>
case "edit":
<path d="M4 20h4L18 10l-4-4L4 16z"></path>
<path d="m14 6 4 4"></path>
case "info":
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 11v5M12 8h.01"></path>
case "alert":
<path d="M12 3 2 20h20z"></path>
<path d="M12 10v4M12 17h.01"></path>
case "clock":
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 7v5l3 2"></path>
case "sync":
<path d="M3 8a9 9 0 0 1 15-3l3 3"></path>
<path d="M21 16a9 9 0 0 1-15 3l-3-3"></path>
case "download":
<path d="M12 3v12M7 10l5 5 5-5"></path>
<path d="M5 21h14"></path>
case "upload":
<path d="M12 21V9M7 14l5-5 5 5"></path>
<path d="M5 3h14"></path>
case "copy":
<rect x="9" y="9" width="12" height="12" rx="2"></rect>
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"></path>
case "external":
<path d="M14 4h6v6"></path>
<path d="M20 4 10 14"></path>
<path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"></path>
case "list":
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"></path>
case "grid":
<rect x="3" y="3" width="7" height="7" rx="1"></rect>
<rect x="14" y="3" width="7" height="7" rx="1"></rect>
<rect x="3" y="14" width="7" height="7" rx="1"></rect>
<rect x="14" y="14" width="7" height="7" rx="1"></rect>
case "play":
<path d="M6 4l14 8-14 8z"></path>
case "heart":
<path d="M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z"></path>
case "calendar":
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
<path d="M3 9h18M8 2v4M16 2v4"></path>
case "tag":
<path d="M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z"></path>
<circle cx="7.5" cy="7.5" r="1"></circle>
case "globe":
<circle cx="12" cy="12" r="9"></circle>
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"></path>
case "shield":
<path d="M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z"></path>
case "database":
<ellipse cx="12" cy="5" rx="8" ry="3"></ellipse>
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"></path>
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path>
default:
<circle cx="12" cy="12" r="9"></circle>
}
</svg>
}
+348
View File
@@ -0,0 +1,348 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// Icon renders an inline SVG from the built-in icon set. All icons share the
// same 24x24 viewBox, 1.75 stroke width, and round line caps so the UI has one
// consistent visual language (replacing the mixed emoji/SVG iconography).
// `extra` is appended to the class list (e.g. "h-5 w-5").
func Icon(name string, extra 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_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{"reader-icon " + extra}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/icons.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
switch name {
case "search":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<circle cx=\"11\" cy=\"11\" r=\"7\"></circle> <path d=\"m20 20-3.5-3.5\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "user":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<circle cx=\"12\" cy=\"8\" r=\"4\"></circle> <path d=\"M4 20c0-4 4-6 8-6s8 2 8 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "users":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<circle cx=\"9\" cy=\"8\" r=\"3.5\"></circle> <path d=\"M2 20c0-3.5 3-5 7-5s7 1.5 7 5\"></path> <path d=\"M16 4.5a3.5 3.5 0 0 1 0 7\"></path> <path d=\"M22 20c0-3-2-4.5-5-4.5\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "palette":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <circle cx=\"8\" cy=\"10\" r=\"1\"></circle> <circle cx=\"12\" cy=\"7.5\" r=\"1\"></circle> <circle cx=\"15.5\" cy=\"10\" r=\"1\"></circle> <path d=\"M12 21a3 3 0 0 0 0-6 2 2 0 0 1 0-4h1\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "menu":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<path d=\"M3 6h18M3 12h18M3 18h18\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chevron-down":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<path d=\"m6 9 6 6 6-6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chevron-up":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<path d=\"m6 15 6-6 6 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chevron-left":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<path d=\"m15 6-6 6 6 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chevron-right":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<path d=\"m9 6 6 6-6 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chevrons-left":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<path d=\"m11 6-6 6 6 6M19 6l-6 6 6 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chevrons-right":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<path d=\"m13 6 6 6-6 6M5 6l6 6-6 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "close":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<path d=\"M6 6l12 12M18 6 6 18\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "grip":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<circle cx=\"9\" cy=\"6\" r=\"1\"></circle> <circle cx=\"15\" cy=\"6\" r=\"1\"></circle> <circle cx=\"9\" cy=\"12\" r=\"1\"></circle> <circle cx=\"15\" cy=\"12\" r=\"1\"></circle> <circle cx=\"9\" cy=\"18\" r=\"1\"></circle> <circle cx=\"15\" cy=\"18\" r=\"1\"></circle>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "settings":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "gear":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "refresh":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<path d=\"M3 12a9 9 0 0 1 15-6.7L21 8\"></path> <path d=\"M21 3v5h-5\"></path> <path d=\"M21 12a9 9 0 0 1-15 6.7L3 16\"></path> <path d=\"M3 21v-5h5\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "book":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"></path> <path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "book-open":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<path d=\"M12 7v14\"></path> <path d=\"M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z\"></path> <path d=\"M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "library":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<path d=\"M3 21h18\"></path> <path d=\"M5 21V7l7-4 7 4v14\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "layers":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "folder":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chart":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "device":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "trash":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "save":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "filter":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "check":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"m4 12 5 5 11-11\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "check-circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "x-circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "bookmark":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "star":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "arrow-right":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "arrow-left":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "home":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "logout":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M12 5v14M5 12h14\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "edit":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "info":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "alert":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "clock":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "sync":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "download":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "upload":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "copy":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "external":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "list":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "grid":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "play":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M6 4l14 8-14 8z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "heart":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "calendar":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "tag":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "globe":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "shield":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "database":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+71 -83
View File
@@ -12,108 +12,96 @@ templ Index(loggedIn bool) {
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-tokyo-night" x-data="index" x-init="initIndexTheme(); checkAuthRedirect()">
<!-- Hero Section -->
<div class="relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div>
<div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
<div class="flex justify-between items-start mb-8">
<div></div>
<select id="theme-select" @change="changeTheme" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
<option value="tokyo-night">Tokyo Night</option>
<option value="dracula">Dracula</option>
<option value="nord">Nord</option>
<option value="solarized-dark">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="one-dark-pro">One Dark Pro</option>
<option value="material-dark">Material Dark</option>
<option value="catppuccin-mocha">Catppuccin Mocha</option>
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
<option value="catppuccin-frappe">Catppuccin Frappé</option>
<option value="catppuccin-latte">Catppuccin Latte</option>
</select>
<div class="absolute top-4 right-4 z-10">
<select
id="theme-select"
@change="changeTheme"
class="input w-auto py-1.5 pr-8 text-sm cursor-pointer"
aria-label="Theme"
>
for _, opt := range ThemeOptions {
<option value={ opt.Name }>{ opt.Label }</option>
}
</select>
</div>
<!-- Hero -->
<div class="brand-gradient relative overflow-hidden">
<div class="max-w-7xl mx-auto px-4 py-20 sm:px-6 sm:py-28 lg:py-36 lg:px-8 text-center">
<div class="inline-flex items-center gap-3 mb-6">
<span class="text-5xl">📚</span>
<span class="text-4xl sm:text-6xl font-extrabold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
</div>
<div class="text-center">
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
📚 Bookhoard
</h1>
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)">
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.
</p>
<div class="mt-10">
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg">
Get Started
</a>
</div>
<p class="mt-4 max-w-2xl mx-auto text-lg sm:text-xl" style="color: var(--text-secondary)">
Your personal ebook management system. Track reading progress, organize your library, and sync across every device.
</p>
<div class="mt-10 flex items-center justify-center gap-3">
if loggedIn {
<a href="/dashboard" class="btn btn-primary px-8 py-3 text-base">Go to Library</a>
} else {
<a href="#auth" class="btn btn-primary px-8 py-3 text-base">Get Started</a>
<a href="#features" class="btn btn-secondary px-6 py-3 text-base">Learn more</a>
}
</div>
</div>
</div>
<!-- Features Section -->
<div class="py-16" style="background-color: var(--bg-secondary)">
<!-- Features -->
<div id="features" class="py-16 sm:py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookhoard?</h2>
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p>
<h2 class="text-3xl font-extrabold tracking-tight" style="color: var(--text-primary)">Why Bookhoard?</h2>
<p class="mt-3 text-lg" style="color: var(--text-secondary)">Everything you need to manage your ebook library</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="card p-6 rounded-xl border">
<div class="text-center">
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4">
<span class="text-white text-2xl">📖</span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card p-6">
<div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("book-open", "h-6 w-6")
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
<p class="text-sm" style="color: var(--text-secondary)">Track progress across all your ebooks with detailed statistics and sync.</p>
</div>
<div class="card p-6 rounded-xl border">
<div class="text-center">
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4">
<span class="text-white text-2xl">🎨</span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p>
<div class="card p-6">
<div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("palette", "h-6 w-6")
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
<p class="text-sm" style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin.</p>
</div>
<div class="card p-6 rounded-xl border">
<div class="text-center">
<div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4">
<span class="text-white text-2xl"></span>
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3>
<p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p>
<div class="card p-6">
<div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("device", "h-6 w-6")
</div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Cross-Device Sync</h3>
<p class="text-sm" style="color: var(--text-secondary)">Built with Go and HTMX for fast performance and seamless device syncing.</p>
</div>
</div>
</div>
</div>
<!-- Auth Section -->
<!-- Auth -->
if !loggedIn {
<div id="auth" class="py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookhoard Today</h2>
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
<div id="auth" class="pb-20 px-4">
<div class="max-w-md mx-auto">
<div class="text-center mb-8">
<h2 class="text-2xl font-extrabold tracking-tight" style="color: var(--text-primary)">Welcome back</h2>
<p class="mt-2" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
</div>
<div class="grid grid-cols-1 gap-8">
<div class="card p-8 rounded-xl border">
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3>
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
Sign In
</button>
</div>
</form>
</div>
<div class="card p-8">
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML" class="space-y-4">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="input" required/>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="input" required/>
</div>
<button type="submit" class="btn btn-primary w-full py-2.5">Sign In</button>
</form>
<p class="text-center text-sm mt-4" style="color: var(--text-secondary)">
No account?
<a href="/register" class="font-medium hover:underline" style="color: var(--accent)">Register</a>
</p>
</div>
<div id="auth-result" class="mt-8 text-center"></div>
<div id="auth-result" class="mt-4 text-center text-sm"></div>
</div>
</div>
}
+79 -4
View File
@@ -29,17 +29,92 @@ func Index(loggedIn bool) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
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>Bookhoard - Home</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night\" x-data=\"index\" x-init=\"initIndexTheme(); checkAuthRedirect()\"><!-- Hero Section --><div class=\"relative overflow-hidden\"><div class=\"absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10\"></div><div class=\"relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8\"><div class=\"flex justify-between items-start mb-8\"><div></div><select id=\"theme-select\" @change=\"changeTheme\" class=\"px-3 py-2 border rounded text-sm\" style=\"background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)\"><option value=\"tokyo-night\">Tokyo Night</option> <option value=\"dracula\">Dracula</option> <option value=\"nord\">Nord</option> <option value=\"solarized-dark\">Solarized Dark</option> <option value=\"monokai\">Monokai</option> <option value=\"one-dark-pro\">One Dark Pro</option> <option value=\"material-dark\">Material Dark</option> <option value=\"catppuccin-mocha\">Catppuccin Mocha</option> <option value=\"catppuccin-macchiato\">Catppuccin Macchiato</option> <option value=\"catppuccin-frappe\">Catppuccin Frappé</option> <option value=\"catppuccin-latte\">Catppuccin Latte</option></select></div><div class=\"text-center\"><h1 class=\"text-4xl sm:text-6xl lg:text-7xl font-extrabold\" style=\"color: var(--text-primary)\">📚 Bookhoard</h1><p class=\"mt-6 max-w-2xl mx-auto text-xl\" style=\"color: var(--text-secondary)\">Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.</p><div class=\"mt-10\"><a href=\"#auth\" class=\"btn-primary px-8 py-3 rounded-lg font-medium text-lg\">Get Started</a></div></div></div></div><!-- Features Section --><div class=\"py-16\" style=\"background-color: var(--bg-secondary)\"><div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"text-center mb-12\"><h2 class=\"text-3xl font-extrabold\" style=\"color: var(--text-primary)\">Why Bookhoard?</h2><p class=\"mt-4 text-lg\" style=\"color: var(--text-secondary)\">Discover the features that make ebook management effortless</p></div><div class=\"grid grid-cols-1 md:grid-cols-3 gap-8\"><div class=\"card p-6 rounded-xl border\"><div class=\"text-center\"><div class=\"mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4\"><span class=\"text-white text-2xl\">📖</span></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Reading Progress</h3><p style=\"color: var(--text-secondary)\">Track your reading progress across all your ebooks with detailed statistics.</p></div></div><div class=\"card p-6 rounded-xl border\"><div class=\"text-center\"><div class=\"mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4\"><span class=\"text-white text-2xl\">🎨</span></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Beautiful Themes</h3><p style=\"color: var(--text-secondary)\">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p></div></div><div class=\"card p-6 rounded-xl border\"><div class=\"text-center\"><div class=\"mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4\"><span class=\"text-white text-2xl\">⚡</span></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Fast & Modern</h3><p style=\"color: var(--text-secondary)\">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p></div></div></div></div></div><!-- Auth Section -->")
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>Bookhoard - Home</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night\" x-data=\"index\" x-init=\"initIndexTheme(); checkAuthRedirect()\"><div class=\"absolute top-4 right-4 z-10\"><select id=\"theme-select\" @change=\"changeTheme\" class=\"input w-auto py-1.5 pr-8 text-sm cursor-pointer\" aria-label=\"Theme\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !loggedIn {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"auth\" class=\"py-16\"><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"text-center mb-12\"><h2 class=\"text-3xl font-extrabold\" style=\"color: var(--text-primary)\">Join Bookhoard Today</h2><p class=\"mt-4 text-lg\" style=\"color: var(--text-secondary)\">Sign in to start managing your ebook library</p></div><div class=\"grid grid-cols-1 gap-8\"><div class=\"card p-8 rounded-xl border\"><h3 class=\"text-2xl font-semibold mb-6 text-center\" style=\"color: var(--text-primary)\">Login</h3><form hx-post=\"/api/auth/login\" hx-target=\"#auth-result\" hx-swap=\"innerHTML\"><div class=\"space-y-4\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><button type=\"submit\" class=\"w-full btn-primary py-2 rounded-lg font-medium\">Sign In</button></div></form></div></div><div id=\"auth-result\" class=\"mt-8 text-center\"></div></div></div>")
for _, opt := range ThemeOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 23, Col: 30}
}
_, 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, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 23, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</select></div><!-- Hero --><div class=\"brand-gradient relative overflow-hidden\"><div class=\"max-w-7xl mx-auto px-4 py-20 sm:px-6 sm:py-28 lg:py-36 lg:px-8 text-center\"><div class=\"inline-flex items-center gap-3 mb-6\"><span class=\"text-5xl\">📚</span> <span class=\"text-4xl sm:text-6xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></div><p class=\"mt-4 max-w-2xl mx-auto text-lg sm:text-xl\" style=\"color: var(--text-secondary)\">Your personal ebook management system. Track reading progress, organize your library, and sync across every device.</p><div class=\"mt-10 flex items-center justify-center gap-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if loggedIn {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/dashboard\" class=\"btn btn-primary px-8 py-3 text-base\">Go to Library</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"#auth\" class=\"btn btn-primary px-8 py-3 text-base\">Get Started</a> <a href=\"#features\" class=\"btn btn-secondary px-6 py-3 text-base\">Learn more</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></div><!-- Features --><div id=\"features\" class=\"py-16 sm:py-20\"><div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"text-center mb-12\"><h2 class=\"text-3xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Why Bookhoard?</h2><p class=\"mt-3 text-lg\" style=\"color: var(--text-secondary)\">Everything you need to manage your ebook library</p></div><div class=\"grid grid-cols-1 md:grid-cols-3 gap-6\"><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Reading Progress</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track progress across all your ebooks with detailed statistics and sync.</p></div><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("palette", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Beautiful Themes</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin.</p></div><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("device", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Cross-Device Sync</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Built with Go and HTMX for fast performance and seamless device syncing.</p></div></div></div></div><!-- Auth -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !loggedIn {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div id=\"auth\" class=\"pb-20 px-4\"><div class=\"max-w-md mx-auto\"><div class=\"text-center mb-8\"><h2 class=\"text-2xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Welcome back</h2><p class=\"mt-2\" style=\"color: var(--text-secondary)\">Sign in to start managing your ebook library</p></div><div class=\"card p-8\"><form hx-post=\"/api/auth/login\" hx-target=\"#auth-result\" hx-swap=\"innerHTML\" class=\"space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full py-2.5\">Sign In</button></form><p class=\"text-center text-sm mt-4\" style=\"color: var(--text-secondary)\">No account? <a href=\"/register\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Register</a></p></div><div id=\"auth-result\" class=\"mt-4 text-center text-sm\"></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+19 -19
View File
@@ -1,24 +1,26 @@
package templates
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...templ.Component) {
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<div
class="app-subbar backdrop-blur border-b"
style="background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);"
>
<div class="w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library</label>
<select
id="library-select"
name="library_id"
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
class="input w-auto py-1.5 pr-8 cursor-pointer"
>
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
}
}
}
</select>
</div>
if len(actions) > 0 {
@@ -42,18 +44,16 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
templ DashboardActions() {
<button
data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
class="icon-btn"
title="Customize Dashboard"
>
⚙️
@Icon("grid", "h-5 w-5")
</button>
<button
data-action="reload-page"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
class="icon-btn"
title="Refresh"
>
🔄
@Icon("refresh", "h-5 w-5")
</button>
}
+25 -9
View File
@@ -29,14 +29,14 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"app-subbar backdrop-blur border-b\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><div class=\"w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4\"><div class=\"flex items-center gap-3\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"library-select\" name=\"library_id\" class=\"input w-auto py-1.5 pr-8 cursor-pointer\"><option value=\"\">All Libraries (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 14, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -55,7 +55,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -68,7 +68,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -81,7 +81,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -99,7 +99,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -112,7 +112,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -125,7 +125,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -186,7 +186,23 @@ func DashboardActions() templ.Component {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("grid", "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, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "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, 17, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+45 -48
View File
@@ -6,60 +6,57 @@ templ Login(sessionExpired bool, deleted bool) {
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Login</title>
<title>Login - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script type="module" src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-tokyo-night min-h-screen" x-data="login" x-init="initLoginTheme()">
<div class="container mx-auto px-4 py-8 max-w-md">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold">Login to Bookhoard</h1>
<select id="theme-select" @change="changeTheme()" class="px-3 py-2 border rounded" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
<option value="tokyo-night">Tokyo Night</option>
<option value="dracula">Dracula</option>
<option value="nord">Nord</option>
<option value="solarized-dark">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="one-dark-pro">One Dark Pro</option>
<option value="material-dark">Material Dark</option>
<option value="catppuccin-mocha">Catppuccin Mocha</option>
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
<option value="catppuccin-frappe">Catppuccin Frappé</option>
<option value="catppuccin-latte">Catppuccin Latte</option>
</select>
<div class="brand-gradient min-h-screen flex flex-col items-center justify-center p-4 relative">
<select
id="theme-select"
@change="changeTheme()"
class="input absolute top-4 right-4 w-auto py-1.5 pr-8 text-sm cursor-pointer"
aria-label="Theme"
>
for _, opt := range ThemeOptions {
<option value={ opt.Name }>{ opt.Label }</option>
}
</select>
<div class="w-full max-w-sm">
<a href="/" class="flex items-center justify-center gap-2.5 mb-8">
<span class="text-3xl">📚</span>
<span class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
</a>
if sessionExpired {
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid var(--accent);">
@Icon("info", "h-5 w-5 shrink-0")
<p class="text-sm" style="color: var(--text-primary)">Your session has expired. Please log in again to continue.</p>
</div>
}
if deleted {
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-success) 14%, transparent); border: 1px solid var(--status-success);">
@Icon("check-circle", "h-5 w-5 shrink-0")
<p class="text-sm" style="color: var(--text-primary)">Your account has been deleted successfully.</p>
</div>
}
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 space-y-4">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="input" required/>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="input" required/>
</div>
<button type="submit" class="btn btn-primary w-full py-2.5">Sign In</button>
</form>
<div id="result" class="mt-4 text-center text-sm"></div>
<p class="text-center mt-6 text-sm" style="color: var(--text-secondary)">
Don't have an account?
<a href="/register" class="font-medium hover:underline" style="color: var(--accent)">Sign up</a>
</p>
</div>
if sessionExpired {
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
<p style="color: var(--text-primary);">
Your session has expired. Please log in again to continue.
</p>
</div>
}
if deleted {
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
<p style="color: var(--text-primary);">
Your account has been deleted successfully.
</p>
</div>
}
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
<div class="mb-4">
<label class="block mb-2" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<div class="mb-4">
<label class="block mb-2" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded">
Sign In
</button>
</form>
<div id="result" class="mt-4 text-center"></div>
<p class="text-center mt-4">
<a href="/register" class="text-blue-500 hover:underline">Don't have an account? Sign Up</a>
</p>
</div>
</body>
</html>
+56 -4
View File
@@ -29,23 +29,75 @@ func Login(sessionExpired bool, deleted bool) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
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>Login</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night min-h-screen\" x-data=\"login\" x-init=\"initLoginTheme()\"><div class=\"container mx-auto px-4 py-8 max-w-md\"><div class=\"flex justify-between items-center mb-8\"><h1 class=\"text-3xl font-bold\">Login to Bookhoard</h1><select id=\"theme-select\" @change=\"changeTheme()\" class=\"px-3 py-2 border rounded\" style=\"background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)\"><option value=\"tokyo-night\">Tokyo Night</option> <option value=\"dracula\">Dracula</option> <option value=\"nord\">Nord</option> <option value=\"solarized-dark\">Solarized Dark</option> <option value=\"monokai\">Monokai</option> <option value=\"one-dark-pro\">One Dark Pro</option> <option value=\"material-dark\">Material Dark</option> <option value=\"catppuccin-mocha\">Catppuccin Mocha</option> <option value=\"catppuccin-macchiato\">Catppuccin Macchiato</option> <option value=\"catppuccin-frappe\">Catppuccin Frappé</option> <option value=\"catppuccin-latte\">Catppuccin Latte</option></select></div>")
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>Login - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night min-h-screen\" x-data=\"login\" x-init=\"initLoginTheme()\"><div class=\"brand-gradient min-h-screen flex flex-col items-center justify-center p-4 relative\"><select id=\"theme-select\" @change=\"changeTheme()\" class=\"input absolute top-4 right-4 w-auto py-1.5 pr-8 text-sm cursor-pointer\" aria-label=\"Theme\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, opt := range ThemeOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 23, Col: 30}
}
_, 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, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 23, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</select><div class=\"w-full max-w-sm\"><a href=\"/\" class=\"flex items-center justify-center gap-2.5 mb-8\"><span class=\"text-3xl\">📚</span> <span class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sessionExpired {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mb-4 p-3 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--accent);\"><p style=\"color: var(--text-primary);\">Your session has expired. Please log in again to continue.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"mb-4 p-3 rounded-xl flex items-start gap-2\" style=\"background-color: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your session has expired. Please log in again to continue.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if deleted {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"mb-4 p-3 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--accent);\"><p style=\"color: var(--text-primary);\">Your account has been deleted successfully.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"mb-4 p-3 rounded-xl flex items-start gap-2\" style=\"background-color: color-mix(in srgb, var(--status-success) 14%, transparent); border: 1px solid var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your account has been deleted successfully.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 rounded-lg shadow-md border\"><div class=\"mb-4\"><label class=\"block mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><button type=\"submit\" class=\"btn-primary w-full py-2 rounded\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center\"></div><p class=\"text-center mt-4\"><a href=\"/register\" class=\"text-blue-500 hover:underline\">Don't have an account? Sign Up</a></p></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full py-2.5\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center text-sm\"></div><p class=\"text-center mt-6 text-sm\" style=\"color: var(--text-secondary)\">Don't have an account? <a href=\"/register\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Sign up</a></p></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+23 -12
View File
@@ -11,21 +11,32 @@ templ Profile(user User) {
</head>
<body class="theme-{ user.Theme }" x-data="profile">
@Header(user, "/profile")
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Profile Settings</h1>
<p style="color: var(--text-secondary)">Manage your account information and preferences</p>
<main class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8 flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("user", "h-5 w-5")
</span>
<div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Profile Settings</h1>
<p class="text-sm mt-0.5" style="color: var(--text-secondary)">Manage your account information and preferences</p>
</div>
</div>
<div class="space-y-8">
<!-- Account Information & Password - uses reusable ProfileForm -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="space-y-6">
<div class="card p-6">
@ProfileForm(user, "/api/auth/profile", true, false, false)
</div>
<!-- Danger Zone -->
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Danger Zone</h3>
<p style="color: var(--text-secondary)" class="mb-4">Once you delete your account, there is no going back. Please be certain.</p>
<button @click="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;">
<div class="card p-6">
<div class="flex items-start gap-3 mb-4">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: color-mix(in srgb, var(--status-danger) 18%, transparent); color: var(--status-danger);">
@Icon("alert", "h-5 w-5")
</span>
<div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Danger Zone</h3>
<p class="text-sm mt-0.5" style="color: var(--text-secondary)">Once you delete your account, there is no going back. Please be certain.</p>
</div>
</div>
<button @click="confirmDeleteAccount()" class="btn btn-danger">
@Icon("trash", "h-4 w-4")
Remove My Account
</button>
</div>
+40 -52
View File
@@ -6,60 +6,57 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
hx-target="#profile-result"
hx-swap="innerHTML"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
class="space-y-6"
class="space-y-8"
>
<!-- Account Information -->
<div>
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Account Information</h3>
<div class="flex items-center gap-2 mb-4">
@Icon("user", "h-5 w-5")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Account Information</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Username</label>
<input
type="text"
name="username"
value={ user.Username }
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email Address</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Email Address</label>
<input
type="email"
name="email"
value={ user.Email }
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">First Name</label>
<input
type="text"
name="first_name"
value={ user.FirstName }
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Last Name</label>
<input
type="text"
name="last_name"
value={ user.LastName }
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Theme</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Theme</label>
<select
name="theme"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="tokyo-night" selected?={ user.Theme == "tokyo-night" }>Tokyo Night</option>
<option value="dracula" selected?={ user.Theme == "dracula" }>Dracula</option>
@@ -71,11 +68,10 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Timezone</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
<select
name="timezone"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (UTC+0)</option>
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
@@ -105,11 +101,10 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
</div>
if showRoleField {
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Role</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Role</label>
<select
name="role"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="user" selected?={ user.Role == "user" }>User</option>
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
@@ -118,39 +113,37 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
}
</div>
</div>
<!-- Change Password Section -->
<div>
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Change Password</h3>
<div class="flex items-center gap-2 mb-4">
@Icon("shield", "h-5 w-5")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Change Password</h3>
</div>
if requireCurrentPassword {
<!-- Self-edit: require current password -->
<div class="space-y-4 max-w-md">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Current Password</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Current Password</label>
<input
type="password"
name="current_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
required
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">New Password</label>
<input
type="password"
name="new_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
minlength="6"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Confirm New Password</label>
<input
type="password"
name="confirm_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
minlength="6"
/>
</div>
@@ -161,36 +154,33 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
hx-target="#password-result"
hx-swap="innerHTML"
hx-include="closest form"
class="px-4 py-2 rounded"
style="background-color: var(--accent); color: white;"
class="btn btn-primary"
>
@Icon("save", "h-4 w-4")
Update Password
</button>
<div id="password-result" class="mt-2"></div>
</div>
} else {
<!-- Admin edit: no current password required -->
<p class="text-sm italic mb-4" style="color: var(--text-secondary);">
As an admin, you can change this user's password without knowing their current password.
</p>
<div class="space-y-4 max-w-md">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">New Password</label>
<input
type="password"
name="new_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
minlength="6"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Confirm New Password</label>
<input
type="password"
name="confirm_password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
minlength="6"
/>
</div>
@@ -201,32 +191,30 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
hx-target="#password-result"
hx-swap="innerHTML"
hx-include="closest form"
class="px-4 py-2 rounded"
style="background-color: var(--accent); color: white;"
class="btn btn-primary"
>
@Icon("save", "h-4 w-4")
Reset Password
</button>
<div id="password-result" class="mt-2"></div>
</div>
}
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);">
if showCancelButton {
<button
type="button"
@click="closeProfileModal()"
class="px-4 py-2 rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
class="btn btn-secondary"
>
Cancel
</button>
}
<button
type="submit"
class="px-4 py-2 rounded"
style="background-color: var(--accent); color: white;"
class="btn btn-primary"
>
@Icon("save", "h-4 w-4")
Save Changes
</button>
</div>
+123 -83
View File
@@ -42,33 +42,41 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" hx-target=\"#profile-result\" hx-swap=\"innerHTML\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' class=\"space-y-6\"><!-- Account Information --><div><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Account Information</h3><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Username</label> <input type=\"text\" name=\"username\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" hx-target=\"#profile-result\" hx-swap=\"innerHTML\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' class=\"space-y-8\"><div><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("user", "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, 3, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Account Information</h3></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Username</label> <input type=\"text\" name=\"username\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 20, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 22, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Email Address</label> <input type=\"email\" name=\"email\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Email Address</label> <input type=\"email\" name=\"email\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 31, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">First Name</label> <input type=\"text\" name=\"first_name\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">First Name</label> <input type=\"text\" name=\"first_name\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -81,398 +89,430 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" placeholder=\"Optional\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Last Name</label> <input type=\"text\" name=\"last_name\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"Optional\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Last Name</label> <input type=\"text\" name=\"last_name\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 51, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 50, Col: 27}
}
_, 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, 6, "\" placeholder=\"Optional\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Theme</label> <select name=\"theme\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"tokyo-night\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" placeholder=\"Optional\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Theme</label> <select name=\"theme\" class=\"input\"><option value=\"tokyo-night\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Theme == "tokyo-night" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ">Tokyo Night</option> <option value=\"dracula\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">Tokyo Night</option> <option value=\"dracula\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Theme == "dracula" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">Dracula</option> <option value=\"nord\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Dracula</option> <option value=\"nord\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Theme == "nord" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ">Nord</option> <option value=\"solarized-dark\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Nord</option> <option value=\"solarized-dark\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Theme == "solarized-dark" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Solarized Dark</option> <option value=\"monokai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Solarized Dark</option> <option value=\"monokai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Theme == "monokai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">Monokai</option> <option value=\"one-dark-pro\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Monokai</option> <option value=\"one-dark-pro\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Theme == "one-dark-pro" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">One Dark Pro</option> <option value=\"material-dark\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">One Dark Pro</option> <option value=\"material-dark\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Theme == "material-dark" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">Material Dark</option></select></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"UTC\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "UTC" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Denver" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Phoenix" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Chicago" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/New_York" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Sao_Paulo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/London" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Paris" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Helsinki" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Moscow" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Tehran" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Dubai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Karachi" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Kolkata" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Dhaka" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Bangkok" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Shanghai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Tokyo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Darwin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Sydney" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Auckland" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, ">New Zealand (UTC+12/+13)</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">New Zealand (UTC+12/+13)</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showRoleField {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"user\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">User</option> <option value=\"admin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Admin</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">Admin</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div></div><!-- Change Password Section --><div><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Change Password</h3>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div><div><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("shield", "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, 76, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if requireCurrentPassword {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<!-- Self-edit: require current password --> <div class=\"space-y-4 max-w-md\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" required></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" minlength=\"6\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" name=\"confirm_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" minlength=\"6\"></div><button type=\"button\" hx-put=\"/api/auth/password\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"px-4 py-2 rounded\" style=\"background-color: var(--accent); color: white;\">Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"input\" minlength=\"6\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" name=\"confirm_password\" class=\"input\" minlength=\"6\"></div><button type=\"button\" hx-put=\"/api/auth/password\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<!-- Admin edit: no current password required --> <p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" minlength=\"6\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" name=\"confirm_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" minlength=\"6\"></div><button type=\"button\" hx-put=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"input\" minlength=\"6\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" name=\"confirm_password\" class=\"input\" minlength=\"6\"></div><button type=\"button\" hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 199, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 189, Col: 46}
}
_, 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, 77, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"px-4 py-2 rounded\" style=\"background-color: var(--accent); color: white;\">Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</div><!-- Actions --><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showCancelButton {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"px-4 py-2 rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\">Cancel</button> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<button type=\"submit\" class=\"px-4 py-2 rounded\" style=\"background-color: var(--accent); color: white;\">Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+16 -14
View File
@@ -1,26 +1,28 @@
package templates
templ ProfileModal(user User) {
<div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" x-data="profileModal" x-init="setupProfileModal()" style="background-color: rgba(0,0,0,0.5);">
<div class="rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary);">
<!-- Modal Header -->
<div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
<div>
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Edit User Profile</h2>
<p class="text-sm mt-1" style="color: var(--text-secondary);">
{ user.Username } ({ user.Email })
</p>
<div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50 p-4" x-data="profileModal" x-init="setupProfileModal()" style="background-color: var(--surface-overlay);">
<div class="card w-full max-w-4xl max-h-[90vh] overflow-y-auto" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-start gap-4 p-6 border-b" style="border-color: var(--border);">
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("edit", "h-5 w-5")
</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit User Profile</h2>
<p class="text-sm mt-0.5" style="color: var(--text-secondary);">
{ user.Username } ({ user.Email })
</p>
</div>
</div>
<button
@click="closeProfileModal()"
class="text-2xl"
style="color: var(--text-secondary);"
class="icon-btn shrink-0"
aria-label="Close"
>
&times;
@Icon("close", "h-5 w-5")
</button>
</div>
<!-- Modal Body - uses reusable form -->
<div class="p-6">
@ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true)
</div>
+22 -6
View File
@@ -29,33 +29,49 @@ func ProfileModal(user User) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"profile-modal\" class=\"fixed inset-0 flex items-center justify-center z-50\" x-data=\"profileModal\" x-init=\"setupProfileModal()\" style=\"background-color: rgba(0,0,0,0.5);\"><div class=\"rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto\" style=\"background-color: var(--bg-secondary);\"><!-- Modal Header --><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><div><h2 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">Edit User Profile</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"profile-modal\" class=\"fixed inset-0 flex items-center justify-center z-50 p-4\" x-data=\"profileModal\" x-init=\"setupProfileModal()\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-4xl max-h-[90vh] overflow-y-auto\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-start gap-4 p-6 border-b\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "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, 2, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit User Profile</h2><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 11, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 14, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 11, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 14, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ")</p></div><button @click=\"closeProfileModal()\" class=\"text-2xl\" style=\"color: var(--text-secondary);\">&times;</button></div><!-- Modal Body - uses reusable form --><div class=\"p-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ")</p></div></div><button @click=\"closeProfileModal()\" class=\"icon-btn shrink-0\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</button></div><div class=\"p-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -63,7 +79,7 @@ func ProfileModal(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+26 -2
View File
@@ -37,7 +37,15 @@ func Profile(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Profile Settings</h1><p style=\"color: var(--text-secondary)\">Manage your account information and preferences</p></div><div class=\"space-y-8\"><!-- Account Information & Password - uses reusable ProfileForm --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("user", "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, 3, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Profile Settings</h1><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary)\">Manage your account information and preferences</p></div></div><div class=\"space-y-6\"><div class=\"card p-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -45,7 +53,23 @@ func Profile(user User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><!-- Danger Zone --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Danger Zone</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Once you delete your account, there is no going back. Please be certain.</p><button @click=\"confirmDeleteAccount()\" class=\"px-4 py-2 rounded text-sm\" style=\"background-color: #dc2626; color: white;\">Remove My Account</button></div></div></main></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"card p-6\"><div class=\"flex items-start gap-3 mb-4\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: color-mix(in srgb, var(--status-danger) 18%, transparent); color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Danger Zone</h3><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary)\">Once you delete your account, there is no going back. Please be certain.</p></div></div><button @click=\"confirmDeleteAccount()\" class=\"btn btn-danger\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Remove My Account</button></div></div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+48 -44
View File
@@ -16,90 +16,94 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
<body class="theme-{ user.Theme }">
@Header(user, "/progress")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Reading Progress</h1>
<p style="color: var(--text-secondary)">Track your reading progress across all devices</p>
<div class="mb-8 flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("book-open", "h-5 w-5")
</span>
<div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Progress</h1>
<p class="text-sm mt-0.5" style="color: var(--text-secondary)">Track your reading progress across all devices</p>
</div>
</div>
<div id="progress-container" class="space-y-6">
if len(progressData) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📖</div>
<div class="card text-center py-16 px-6" style="color: var(--text-secondary)">
<div class="flex justify-center mb-4">
@Icon("book-open", "h-12 w-12 opacity-50")
</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Progress Yet</h3>
<p>Start reading a book to track your progress</p>
</div>
}
for _, item := range progressData {
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card p-6">
<div class="flex gap-6">
<div class="flex-shrink-0">
<img
src="{ item.CoverImagePath }"
src={ item.CoverImagePath }
alt="Cover"
class="w-24 h-32 object-cover rounded"
class="w-24 h-32 object-cover rounded-lg"
onerror="this.src='/static/placeholder-book.svg'"
/>
</div>
<div class="flex-1">
<div class="flex justify-between items-start mb-2">
<div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start gap-4 mb-3">
<div class="min-w-0">
<a
href={ "/media/" + item.MediaItemID.String() }
class="inline-block hover:underline"
style="color: var(--text-primary); text-decoration: none;"
>
<h3 class="font-semibold text-lg hover:opacity-80">{ item.Title }</h3>
<h3 class="font-semibold text-lg">{ item.Title }</h3>
</a>
if item.Author != "" {
<p class="text-sm" style="color: var(--text-secondary)">by { item.Author }</p>
<p class="text-sm mt-0.5" style="color: var(--text-secondary)">by { item.Author }</p>
}
</div>
<div class="text-right">
<span class="text-2xl font-bold" style="color: var(--accent)">{ item.ProgressPercentage }%</span>
</div>
<span class="text-2xl font-bold shrink-0" style="color: var(--accent)">{ fmt.Sprintf("%.2f", item.ProgressPercentage) }%</span>
</div>
<div class="mb-4">
<div class="w-full bg-gray-700 rounded-full h-3">
<div class="h-3 rounded-full transition-all" style={ "width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);" }></div>
<div class="w-full rounded-full h-2.5" style="background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);">
<div class="h-2.5 rounded-full transition-all" style={ "width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);" }></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<p style="color: var(--text-secondary)">Current Position</p>
<p style="color: var(--text-primary)">
if item.FormatGroup == "reflowable" {
if item.EstimatedPages > 0 {
{ fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages) }
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="stat-card">
<span class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Current Position</span>
<p class="text-sm font-medium" style="color: var(--text-primary)">
if item.FormatGroup == "reflowable" {
if item.EstimatedPages > 0 {
{ fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages) }
} else {
{ fmt.Sprintf("%.2f%%", item.ProgressPercentage) }
}
} else {
{ fmt.Sprintf("%.1f%%", item.ProgressPercentage) }
{ fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages) }
}
} else {
{ fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages) }
}
</p>
</p>
</div>
<div>
<p style="color: var(--text-secondary)">Last Updated</p>
<p style="color: var(--text-primary)">{ item.LastUpdated }</p>
<div class="stat-card">
<span class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Last Updated</span>
<p class="text-sm font-medium" style="color: var(--text-primary)">{ item.LastUpdated }</p>
</div>
<div>
<p style="color: var(--text-secondary)">Synced From</p>
<div class="stat-card">
<span class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Synced From</span>
<div class="flex items-center gap-2">
<span class="text-xl">{ item.DeviceIcon }</span>
<span style="color: var(--text-primary)">{ item.DeviceName }</span>
<span class="text-sm font-medium" style="color: var(--text-primary)">{ item.DeviceName }</span>
</div>
</div>
</div>
if item.DeviceIcon != "" {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<div class="flex items-center gap-2 text-xs" style="color: var(--text-secondary);">
<span>🔄</span>
<span>Last sync from <strong>{ item.DeviceName }</strong></span>
<span>({ item.DeviceType })</span>
</div>
<div class="mt-3 pt-3 border-t flex items-center gap-2 text-xs" style="border-color: var(--border); color: var(--text-secondary);">
@Icon("sync", "h-4 w-4")
<span>Last sync from <strong style="color: var(--text-primary)">{ item.DeviceName }</strong></span>
<span>({ item.DeviceType })</span>
</div>
}
if item.EpubCFI != "" {
<div class="mt-2 text-xs" style="color: var(--text-secondary);">
<span>📍</span>
<div class="mt-2 flex items-center gap-2 text-xs" style="color: var(--text-secondary);">
@Icon("bookmark", "h-3.5 w-3.5")
<span>CFI: { item.EpubCFI }</span>
</div>
}
+139 -94
View File
@@ -40,229 +40,274 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">Reading Progress</h1><p style=\"color: var(--text-secondary)\">Track your reading progress across all devices</p></div><div id=\"progress-container\" class=\"space-y-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "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, 3, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Reading Progress</h1><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary)\">Track your reading progress across all devices</p></div></div><div id=\"progress-container\" class=\"space-y-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(progressData) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📖</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Progress Yet</h3><p>Start reading a book to track your progress</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"card text-center py-16 px-6\" style=\"color: var(--text-secondary)\"><div class=\"flex justify-center mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "h-12 w-12 opacity-50").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Progress Yet</h3><p>Start reading a book to track your progress</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, item := range progressData {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex gap-6\"><div class=\"flex-shrink-0\"><img src=\"{ item.CoverImagePath }\" alt=\"Cover\" class=\"w-24 h-32 object-cover rounded\" onerror=\"this.src='/static/placeholder-book.svg'\"></div><div class=\"flex-1\"><div class=\"flex justify-between items-start mb-2\"><div><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card p-6\"><div class=\"flex gap-6\"><div class=\"flex-shrink-0\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID.String())
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 46, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 43, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
_, 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=\"color: var(--text-primary); text-decoration: none;\"><h3 class=\"font-semibold text-lg hover:opacity-80\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" alt=\"Cover\" class=\"w-24 h-32 object-cover rounded-lg\" onerror=\"this.src='/static/placeholder-book.svg'\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex justify-between items-start gap-4 mb-3\"><div class=\"min-w-0\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID.String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 49, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 53, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h3></a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"inline-block hover:underline\" style=\"color: var(--text-primary); text-decoration: none;\"><h3 class=\"font-semibold text-lg\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 57, Col: 58}
}
_, 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, 9, "</h3></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">by ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary)\">by ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 52, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 60, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
_, 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, 8, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><div class=\"text-right\"><span class=\"text-2xl font-bold\" style=\"color: var(--accent)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.ProgressPercentage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 56, Col: 98}
}
_, 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, 10, "%</span></div></div><div class=\"mb-4\"><div class=\"w-full bg-gray-700 rounded-full h-3\"><div class=\"h-3 rounded-full transition-all\" style=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><span class=\"text-2xl font-bold shrink-0\" style=\"color: var(--accent)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);")
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", item.ProgressPercentage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 61, Col: 160}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 63, Col: 127}
}
_, 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, 11, "\"></div></div></div><div class=\"grid grid-cols-1 md:grid-cols-3 gap-4 text-sm\"><div><p style=\"color: var(--text-secondary)\">Current Position</p><p style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "%</span></div><div class=\"mb-4\"><div class=\"w-full rounded-full h-2.5\" style=\"background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);\"><div class=\"h-2.5 rounded-full transition-all\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%.1f", item.ProgressPercentage) + "%; background-color: var(--accent);")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 67, Col: 162}
}
_, 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, 14, "\"></div></div></div><div class=\"grid grid-cols-1 md:grid-cols-3 gap-3\"><div class=\"stat-card\"><span class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Position</span><p class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.FormatGroup == "reflowable" {
if item.EstimatedPages > 0 {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 70, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f%%", item.ProgressPercentage))
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Page %d of %d (est.)", item.CurrentPage, item.EstimatedPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 72, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 76, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f%%", item.ProgressPercentage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 78, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages))
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", item.CurrentPage, item.TotalPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 75, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 81, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
_, 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, 12, "</p></div><div><p style=\"color: var(--text-secondary)\">Last Updated</p><p 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(item.LastUpdated)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 81, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div><div><p style=\"color: var(--text-secondary)\">Synced From</p><div class=\"flex items-center gap-2\"><span class=\"text-xl\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p></div><div class=\"stat-card\"><span class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Last Updated</span><p class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon)
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.LastUpdated)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 86, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 87, Col: 95}
}
_, 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, 14, "</span> <span style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p></div><div class=\"stat-card\"><span class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Synced From</span><div class=\"flex items-center gap-2\"><span class=\"text-xl\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceIcon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 87, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 92, Col: 51}
}
_, 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, 15, "</span></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 93, Col: 98}
}
_, 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, 18, "</span></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.DeviceIcon != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2 text-xs\" style=\"color: var(--text-secondary);\"><span>🔄</span> <span>Last sync from <strong>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"mt-3 pt-3 border-t flex items-center gap-2 text-xs\" style=\"border-color: var(--border); color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 95, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
templ_7745c5c3_Err = Icon("sync", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</strong></span> <span>(")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span>Last sync from <strong style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType)
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 96, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 100, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")</span></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if item.EpubCFI != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"mt-2 text-xs\" style=\"color: var(--text-secondary);\"><span>📍</span> <span>CFI: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</strong></span> <span>(")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 103, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 101, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ")</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></div></div>")
if item.EpubCFI != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mt-2 flex items-center gap-2 text-xs\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("bookmark", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span>CFI: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/progress.templ`, Line: 107, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -270,7 +315,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+63 -55
View File
@@ -14,53 +14,54 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
</head>
<body x-data="queue" class="theme-{ user.Theme }">
@Header(user, "/queue")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<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">
<div class="flex justify-between items-center gap-4 flex-wrap">
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Queue</h1>
<p style="color: var(--text-secondary)">Monitor and manage offline sync queue</p>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("clock", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Queue</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Monitor and manage offline sync queue</p>
</div>
<div class="flex space-x-3">
<button @click="processPendingItems()" class="btn-primary px-4 py-2 rounded-lg">
<button @click="processPendingItems()" class="btn btn-primary">
@Icon("play", "h-4 w-4")
Process Queue
</button>
<button @click="clearFailedItems()" class="btn-secondary px-4 py-2 rounded-lg">
Clear Failed
</button>
<button @click="clearAllItems()" class="btn-danger px-4 py-2 rounded-lg">
Clear All
</button>
<button @click="clearFailedItems()" class="btn btn-secondary">Clear Failed</button>
<button @click="clearAllItems()" class="btn btn-danger">Clear All</button>
</div>
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="card p-4 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="text-3xl font-bold" style="color: #f59e0b;">{ stats.PendingCount }</div>
<div class="text-sm" style="color: var(--text-secondary);">Pending</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-warning);">{ stats.PendingCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Pending</div>
</div>
<div class="card p-4 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="text-3xl font-bold" style="color: #3b82f6;">{ stats.ProcessingCount }</div>
<div class="text-sm" style="color: var(--text-secondary);">Processing</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-info);">{ stats.ProcessingCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Processing</div>
</div>
<div class="card p-4 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="text-3xl font-bold" style="color: #10b981;">{ stats.CompletedCount }</div>
<div class="text-sm" style="color: var(--text-secondary);">Completed</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-success);">{ stats.CompletedCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Completed</div>
</div>
<div class="card p-4 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="text-3xl font-bold" style="color: #ef4444;">{ stats.FailedCount }</div>
<div class="text-sm" style="color: var(--text-secondary);">Failed</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-danger);">{ stats.FailedCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Failed</div>
</div>
</div>
</div>
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex flex-wrap gap-4 items-center">
<div class="card p-4 mb-6">
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Status Filter</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Status Filter</label>
<select
id="status-filter"
onchange="filterQueue()"
class="px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="all">All Items</option>
<option value="pending" selected>Pending</option>
@@ -70,12 +71,11 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Type</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Type</label>
<select
id="type-filter"
onchange="filterQueue()"
class="px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="all">All Types</option>
<option value="progress">Progress</option>
@@ -85,18 +85,18 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device</label>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device</label>
<select
id="device-filter"
onchange="filterQueue()"
class="px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
>
<option value="all">All Devices</option>
</select>
</div>
<div class="flex-1"></div>
<button @click="refreshQueue()" class="btn-secondary px-4 py-2 rounded-lg">
<button @click="refreshQueue()" class="btn btn-secondary">
@Icon("refresh", "h-4 w-4")
Refresh
</button>
</div>
@@ -105,27 +105,31 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<div class="loading-spinner mx-auto mb-4"></div>
<p>Loading queue...</p>
</div>
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📭</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: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">Queue Empty</h3>
<p>No items in the sync queue</p>
<p style="color: var(--text-secondary)">No items in the sync queue</p>
</div>
<div id="queue-list" class="space-y-3">
if len(queueItems) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📭</div>
<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: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">Queue Empty</h3>
<p>No items in the sync queue</p>
<p style="color: var(--text-secondary)">No items in the sync queue</p>
</div>
}
for _, item := range queueItems {
<div class="card p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-3">
<span class="inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase status-{ item.Status }">{ item.Status }</span>
<div class="card p-4 transition-colors" style="cursor: default;">
<div class="flex justify-between items-start mb-2 gap-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="badge status-{ item.Status }">{ item.Status }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ item.SyncType }</span>
</div>
<span class="inline-block px-2 py-0.5 rounded text-[10px] font-semibold priority-{ item.Priority }">P{ item.Priority }</span>
<span class="badge priority-{ item.Priority }">P{ item.Priority }</span>
</div>
<div class="text-sm mb-2" style="color: var(--text-secondary)">
Device ID: { item.DeviceID }
@@ -138,25 +142,29 @@ templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Q
<span>{ item.CreatedAt }</span>
</div>
if item.ErrorMessage != nil {
<div class="mt-2 text-sm" style="color: #ef4444;">
Error: { *item.ErrorMessage }
<div class="mt-2 text-sm flex items-start gap-2" style="color: var(--status-danger);">
@Icon("alert", "h-4 w-4 shrink-0 mt-0.5")
<span>Error: { *item.ErrorMessage }</span>
</div>
}
</div>
}
</div>
<div id="queue-item-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div id="queue-item-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-2xl my-8" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Queue Item Details</h2>
<button @click="hideQueueItemModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
X
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Queue Item Details</h2>
<button @click="hideQueueItemModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<div id="queue-item-details"></div>
<div class="flex justify-end space-x-3 mt-6">
<button @click="hideQueueItemModal()" class="btn-secondary px-4 py-2 rounded-lg">Close</button>
<button @click="retryQueueItem()" class="btn-primary px-4 py-2 rounded-lg">Retry</button>
<button @click="hideQueueItemModal()" class="btn btn-secondary">Close</button>
<button @click="retryQueueItem()" class="btn btn-primary">
@Icon("refresh", "h-4 w-4")
Retry
</button>
</div>
</div>
</div>
+97 -33
View File
@@ -39,208 +39,272 @@ func Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.Qu
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex justify-between items-center\"><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">Sync Queue</h1><p style=\"color: var(--text-secondary)\">Monitor and manage offline sync queue</p></div><div class=\"flex space-x-3\"><button @click=\"processPendingItems()\" class=\"btn-primary px-4 py-2 rounded-lg\">Process Queue</button> <button @click=\"clearFailedItems()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Clear Failed</button> <button @click=\"clearAllItems()\" class=\"btn-danger px-4 py-2 rounded-lg\">Clear All</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-2 md:grid-cols-4 gap-4\"><div class=\"card p-4 rounded-lg border text-center\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"text-3xl font-bold\" style=\"color: #f59e0b;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("clock", "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, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Sync Queue</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Monitor and manage offline sync queue</p></div><div class=\"flex space-x-3\"><button @click=\"processPendingItems()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("play", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Process Queue</button> <button @click=\"clearFailedItems()\" class=\"btn btn-secondary\">Clear Failed</button> <button @click=\"clearAllItems()\" class=\"btn btn-danger\">Clear All</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-2 md:grid-cols-4 gap-4\"><div class=\"stat-card text-center\"><div class=\"text-3xl font-bold\" style=\"color: var(--status-warning);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.PendingCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 38, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 40, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><div class=\"text-sm\" style=\"color: var(--text-secondary);\">Pending</div></div><div class=\"card p-4 rounded-lg border text-center\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"text-3xl font-bold\" style=\"color: #3b82f6;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Pending</div></div><div class=\"stat-card text-center\"><div class=\"text-3xl font-bold\" style=\"color: var(--status-info);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ProcessingCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 42, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 44, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"text-sm\" style=\"color: var(--text-secondary);\">Processing</div></div><div class=\"card p-4 rounded-lg border text-center\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"text-3xl font-bold\" style=\"color: #10b981;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Processing</div></div><div class=\"stat-card text-center\"><div class=\"text-3xl font-bold\" style=\"color: var(--status-success);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.CompletedCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 46, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 48, Col: 99}
}
_, 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, 5, "</div><div class=\"text-sm\" style=\"color: var(--text-secondary);\">Completed</div></div><div class=\"card p-4 rounded-lg border text-center\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"text-3xl font-bold\" style=\"color: #ef4444;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Completed</div></div><div class=\"stat-card text-center\"><div class=\"text-3xl font-bold\" style=\"color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.FailedCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 50, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 52, Col: 95}
}
_, 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, 6, "</div><div class=\"text-sm\" style=\"color: var(--text-secondary);\">Failed</div></div></div></div><div class=\"card p-4 rounded-lg border mb-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Status Filter</label> <select id=\"status-filter\" onchange=\"filterQueue()\" class=\"px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"all\">All Items</option> <option value=\"pending\" selected>Pending</option> <option value=\"processing\">Processing</option> <option value=\"completed\">Completed</option> <option value=\"failed\">Failed</option></select></div><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Sync Type</label> <select id=\"type-filter\" onchange=\"filterQueue()\" class=\"px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"all\">All Types</option> <option value=\"progress\">Progress</option> <option value=\"note\">Notes</option> <option value=\"highlight\">Highlights</option> <option value=\"bookmark\">Bookmarks</option></select></div><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Device</label> <select id=\"device-filter\" onchange=\"filterQueue()\" class=\"px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"all\">All Devices</option></select></div><div class=\"flex-1\"></div><button @click=\"refreshQueue()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Refresh</button></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 queue...</p></div><div id=\"empty-state\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📭</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">Queue Empty</h3><p>No items in the sync queue</p></div><div id=\"queue-list\" class=\"space-y-3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Failed</div></div></div></div><div class=\"card p-4 mb-6\"><div class=\"flex flex-wrap gap-4 items-end\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Status Filter</label> <select id=\"status-filter\" onchange=\"filterQueue()\" class=\"input\"><option value=\"all\">All Items</option> <option value=\"pending\" selected>Pending</option> <option value=\"processing\">Processing</option> <option value=\"completed\">Completed</option> <option value=\"failed\">Failed</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Type</label> <select id=\"type-filter\" onchange=\"filterQueue()\" class=\"input\"><option value=\"all\">All Types</option> <option value=\"progress\">Progress</option> <option value=\"note\">Notes</option> <option value=\"highlight\">Highlights</option> <option value=\"bookmark\">Bookmarks</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device</label> <select id=\"device-filter\" onchange=\"filterQueue()\" class=\"input\"><option value=\"all\">All Devices</option></select></div><div class=\"flex-1\"></div><button @click=\"refreshQueue()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Refresh</button></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 queue...</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: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">Queue Empty</h3><p style=\"color: var(--text-secondary)\">No items in the sync queue</p></div><div id=\"queue-list\" class=\"space-y-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(queueItems) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📭</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">Queue Empty</h3><p>No items in the sync queue</p></div>")
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: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">Queue Empty</h3><p style=\"color: var(--text-secondary)\">No items in the sync queue</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, item := range queueItems {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"card p-4 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-start mb-2\"><div class=\"flex items-center space-x-3\"><span class=\"inline-block px-2 py-0.5 rounded text-[11px] font-semibold uppercase status-{ item.Status }\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card p-4 transition-colors\" style=\"cursor: default;\"><div class=\"flex justify-between items-start mb-2 gap-2\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"badge status-{ item.Status }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 125, Col: 128}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 129, Col: 65}
}
_, 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, 9, "</span> <span class=\"text-sm\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span class=\"text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.SyncType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 126, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 130, Col: 83}
}
_, 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, 10, "</span></div><span class=\"inline-block px-2 py-0.5 rounded text-[10px] font-semibold priority-{ item.Priority }\">P")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></div><span class=\"badge priority-{ item.Priority }\">P")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Priority)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 128, Col: 124}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 132, Col: 71}
}
_, 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, 11, "</span></div><div class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Device ID: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span></div><div class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Device ID: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.DeviceID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 131, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 135, Col: 34}
}
_, 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, 12, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.MediaTitle != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "| Book: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "| Book: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(*item.MediaTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 133, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 137, Col: 35}
}
_, 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, 14, "</div><div class=\"flex justify-between items-center text-sm\" style=\"color: var(--text-secondary)\"><span>Attempts: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"flex justify-between items-center text-sm\" style=\"color: var(--text-secondary)\"><span>Attempts: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Attempts)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 137, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 141, Col: 39}
}
_, 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, 15, "/")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "/")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(item.MaxAttempts)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 137, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 141, Col: 60}
}
_, 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, 16, "</span> <span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> <span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.CreatedAt)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 138, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 30}
}
_, 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, 17, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.ErrorMessage != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"mt-2 text-sm\" style=\"color: #ef4444;\">Error: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mt-2 text-sm flex items-start gap-2\" style=\"color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-4 w-4 shrink-0 mt-0.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span>Error: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(*item.ErrorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 142, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/queue.templ`, Line: 147, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div id=\"queue-item-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-2xl mx-4 my-8\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">Queue Item Details</h2><button @click=\"hideQueueItemModal()\" class=\"p-2 hover:opacity-80 rounded-lg\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\">X</button></div><div id=\"queue-item-details\"></div><div class=\"flex justify-end space-x-3 mt-6\"><button @click=\"hideQueueItemModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Close</button> <button @click=\"retryQueueItem()\" class=\"btn-primary px-4 py-2 rounded-lg\">Retry</button></div></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div><div id=\"queue-item-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-2xl my-8\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Queue Item Details</h2><button @click=\"hideQueueItemModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 28, "</button></div><div id=\"queue-item-details\"></div><div class=\"flex justify-end space-x-3 mt-6\"><button @click=\"hideQueueItemModal()\" class=\"btn btn-secondary\">Close</button> <button @click=\"retryQueueItem()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Retry</button></div></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+28 -26
View File
@@ -36,7 +36,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<title>{ metadata.Title } - Bookhoard Reader</title>
<link rel="manifest" href="/static/manifest.json"/>
<link href="/static/reader-fonts.css" rel="stylesheet"/>
@@ -71,7 +71,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</div>
</div>
</div>
<div id="reader-viewport" class="absolute inset-x-0 top-[52px] bottom-[52px]">
<div id="reader-viewport" class="absolute inset-x-0" style="top: 56px; bottom: 56px;">
<foliate-view id="reader-view" class="block w-full h-full"></foliate-view>
</div>
@DictionaryPopup()
@@ -82,25 +82,25 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
<div id="reader-chrome" class="transition-opacity duration-300">
<!-- Top bar -->
<div class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-4 py-3">
<a href={ "/media/" + metadata.MediaItemID } class="text-lg hover:underline">
<div id="reader-topbar" class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40 pt-[env(safe-area-inset-top)]" style="background-color: var(--bg-primary);">
<div class="flex items-center justify-between px-3 py-2 sm:px-4 sm:py-3">
<a id="reader-back" href={ "/media/" + metadata.MediaItemID } class="text-base sm:text-lg hover:underline">
Back
</a>
<h1 class="text-lg font-semibold">{ metadata.Title }</h1>
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
<button
@click="toggleSettings()"
class="p-2 rounded-lg hover:bg-gray-700"
class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700"
title="Settings"
>
⚙️
</button>
</div>
</div> <!-- Bottom bar -->
<div class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40" style="background-color: var(--bg-primary);">
<div class="flex items-center px-2 py-2 gap-1">
<div id="reader-bottombar" class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40 pb-[env(safe-area-inset-bottom)]" style="background-color: var(--bg-primary);">
<div class="flex items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1">
<!-- Left navigation -->
<button @click="goLeft()" class="p-2 rounded-lg hover:bg-gray-700" title="Go Left" aria-label="Go left">
<button @click="goLeft()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Left" aria-label="Go left">
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 15 6 L 9 12 L 15 18"></path>
</svg>
@@ -118,7 +118,7 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
/>
<datalist id="tick-marks"></datalist>
<!-- Right navigation -->
<button @click="goRight()" class="p-2 rounded-lg hover:bg-gray-700" title="Go Right" aria-label="Go right">
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right" aria-label="Go right">
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 9 6 L 15 12 L 9 18"></path>
</svg>
@@ -129,7 +129,7 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
<template x-if="isFixedLayout">
<div class="flex items-center gap-1">
<!-- Zoom out -->
<button @click="zoomOut()" class="p-2 rounded-lg hover:bg-gray-700" title="Zoom Out" aria-label="Zoom out">
<button @click="zoomOut()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom Out" aria-label="Zoom out">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 5 10 L 15 10"></path>
</svg>
@@ -139,20 +139,20 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
<span x-text="zoomPercent + '%'">100%</span>
</button>
<!-- Zoom in -->
<button @click="zoomIn()" class="p-2 rounded-lg hover:bg-gray-700" title="Zoom In" aria-label="Zoom in">
<button @click="zoomIn()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom In" aria-label="Zoom in">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 10 5 L 10 15 M 5 10 L 15 10"></path>
</svg>
</button>
<!-- Magnifier -->
<button @click="toggleMagnifier()" class="p-2 rounded-lg hover:bg-gray-700" title="Magnifier" aria-label="Toggle magnifier">
<button @click="toggleMagnifier()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Magnifier" aria-label="Toggle magnifier">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="9" cy="9" r="5"></circle>
<path d="M 13 13 L 18 18"></path>
</svg>
</button>
<!-- Pan/Select mode (PDF only) -->
<button x-show="isPDF" @click="toggleInteractionMode()" class="p-2 rounded-lg hover:bg-gray-700" title="Pan/Select Mode" aria-label="Toggle pan/select mode">
<button x-show="isPDF" @click="toggleInteractionMode()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Pan/Select Mode" aria-label="Toggle pan/select mode">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3"></path>
</svg>
@@ -162,24 +162,26 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
<!-- Separator -->
<div class="w-px h-6 bg-gray-600 mx-1"></div>
<!-- Progress display -->
<div id="progress-display" x-text="progressText" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] text-center cursor-pointer">
if progress.FormatGroup == "reflowable" {
if metadata.EstimatedPages > 0 {
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
<div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">
if progress.FormatGroup == "reflowable" {
if metadata.EstimatedPages > 0 {
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
} else {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
}
} else {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
}
} else {
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
}
</span>
</div>
<!-- Separator -->
<div class="w-px h-6 bg-gray-600 mx-1"></div>
<!-- Action buttons -->
<div class="flex items-center gap-1">
<button @click="toggleTOC()" class="p-2 rounded-lg hover:bg-gray-700" title="Table of Contents">📖</button>
<button @click="addBookmark()" class="p-2 rounded-lg hover:bg-gray-700" title="Bookmark">🏷️</button>
<button @click="toggleBookmarks()" class="p-2 rounded-lg hover:bg-gray-700" title="Notes">📝</button>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents">📖</button>
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark">🏷️</button>
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Notes">📝</button>
</div>
</div>
</div>
+14 -14
View File
@@ -60,7 +60,7 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma
templ_7745c5c3_Var1 = templ.NopComponent
}
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>")
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, viewport-fit=cover\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -126,7 +126,7 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></div><div id=\"reader-viewport\" class=\"absolute inset-x-0 top-[52px] bottom-[52px]\"><foliate-view id=\"reader-view\" class=\"block w-full h-full\"></foliate-view></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></div><div id=\"reader-viewport\" class=\"absolute inset-x-0\" style=\"top: 56px; bottom: 56px;\"><foliate-view id=\"reader-view\" class=\"block w-full h-full\"></foliate-view></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -163,33 +163,33 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"reader-chrome\" class=\"transition-opacity duration-300\"><!-- Top bar --><div class=\"fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center justify-between px-4 py-3\"><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"reader-chrome\" class=\"transition-opacity duration-300\"><!-- Top bar --><div id=\"reader-topbar\" class=\"fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40 pt-[env(safe-area-inset-top)]\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center justify-between px-3 py-2 sm:px-4 sm:py-3\"><a id=\"reader-back\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 87, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 87, Col: 63}
}
_, 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, 11, "\" class=\"text-lg hover:underline\">← Back</a><h1 class=\"text-lg font-semibold\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"text-base sm:text-lg hover:underline\">← Back</a><h1 class=\"text-base sm:text-lg font-semibold hidden sm:block sm:truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 90, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 90, Col: 95}
}
_, 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, 12, "</h1><button @click=\"toggleSettings()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Settings\">⚙️</button></div></div><!-- Bottom bar --><div class=\"fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center px-2 py-2 gap-1\"><!-- Left navigation --><button @click=\"goLeft()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button><!-- Progress slider --><input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist><!-- Right navigation --><button @click=\"goRight()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Zoom controls (fixed-layout only) --><template x-if=\"isFixedLayout\"><div class=\"flex items-center gap-1\"><!-- Zoom out --><button @click=\"zoomOut()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Zoom Out\" aria-label=\"Zoom out\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 5 10 L 15 10\"></path></svg></button><!-- Zoom percentage --><button @click=\"resetZoom()\" class=\"text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]\" title=\"Reset Zoom\" aria-label=\"Reset zoom\"><span x-text=\"zoomPercent + '%'\">100%</span></button><!-- Zoom in --><button @click=\"zoomIn()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Zoom In\" aria-label=\"Zoom in\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 10 5 L 10 15 M 5 10 L 15 10\"></path></svg></button><!-- Magnifier --><button @click=\"toggleMagnifier()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Magnifier\" aria-label=\"Toggle magnifier\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><circle cx=\"9\" cy=\"9\" r=\"5\"></circle> <path d=\"M 13 13 L 18 18\"></path></svg></button><!-- Pan/Select mode (PDF only) --><button x-show=\"isPDF\" @click=\"toggleInteractionMode()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Pan/Select Mode\" aria-label=\"Toggle pan/select mode\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3\"></path></svg></button></div></template><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Progress display --><div id=\"progress-display\" x-text=\"progressText\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] text-center cursor-pointer\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h1><button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings\">⚙️</button></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40 pb-[env(safe-area-inset-bottom)]\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1\"><!-- Left navigation --><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button><!-- Progress slider --><input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist><!-- Right navigation --><button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Zoom controls (fixed-layout only) --><template x-if=\"isFixedLayout\"><div class=\"flex items-center gap-1\"><!-- Zoom out --><button @click=\"zoomOut()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Zoom Out\" aria-label=\"Zoom out\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 5 10 L 15 10\"></path></svg></button><!-- Zoom percentage --><button @click=\"resetZoom()\" class=\"text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]\" title=\"Reset Zoom\" aria-label=\"Reset zoom\"><span x-text=\"zoomPercent + '%'\">100%</span></button><!-- Zoom in --><button @click=\"zoomIn()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Zoom In\" aria-label=\"Zoom in\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 10 5 L 10 15 M 5 10 L 15 10\"></path></svg></button><!-- Magnifier --><button @click=\"toggleMagnifier()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Magnifier\" aria-label=\"Toggle magnifier\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><circle cx=\"9\" cy=\"9\" r=\"5\"></circle> <path d=\"M 13 13 L 18 18\"></path></svg></button><!-- Pan/Select mode (PDF only) --><button x-show=\"isPDF\" @click=\"toggleInteractionMode()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Pan/Select Mode\" aria-label=\"Toggle pan/select mode\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3\"></path></svg></button></div></template><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Progress display --><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -198,7 +198,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 168, Col: 112}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 169, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -208,7 +208,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 170, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 171, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -219,14 +219,14 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 173, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 174, Col: 72}
}
_, 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, 13, "</div><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Action buttons --><div class=\"flex items-center gap-1\"><button @click=\"toggleTOC()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents\">📖</button> <button @click=\"addBookmark()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark\">🏷️</button> <button @click=\"toggleBookmarks()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Notes\">📝</button></div></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span></div><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Action buttons --><div class=\"flex items-center gap-1\"><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents\">📖</button> <button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark\">🏷️</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Notes\">📝</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -359,7 +359,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(bookmark.CfiPosition)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 390, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 392, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -372,7 +372,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 393, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 395, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -385,7 +385,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Position)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 395, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 397, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
+68 -59
View File
@@ -6,70 +6,79 @@ templ Register() {
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Register</title>
<title>Register - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script type="module" src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-tokyo-night min-h-screen" x-data="register" x-init="initRegisterTheme()">
<div class="container mx-auto px-4 py-8 max-w-md">
<h1 class="text-3xl font-bold text-center mb-8">Register for Bookhoard</h1>
<form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input type="email" id="email" name="email" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input type="text" id="username" name="username" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input type="text" name="first_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input type="text" name="last_name" placeholder="Optional" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"/>
</div>
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
<ul class="space-y-1" style="color: var(--text-secondary)">
<li id="req-length" class="flex items-center gap-2">
<span class="requirement-icon"></span> At least 8 characters
</li>
<li id="req-upper" class="flex items-center gap-2">
<span class="requirement-icon"></span> One uppercase letter
</li>
<li id="req-lower" class="flex items-center gap-2">
<span class="requirement-icon"></span> One lowercase letter
</li>
<li id="req-number" class="flex items-center gap-2">
<span class="requirement-icon"></span> One number
</li>
<li id="req-special" class="flex items-center gap-2">
<span class="requirement-icon"></span> One special character
</li>
<li id="req-match" class="flex items-center gap-2">
<span class="requirement-icon"></span> Passwords match
</li>
</ul>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" id="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
<input type="password" id="confirm-password" name="confirm_password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
</div>
<button type="submit" id="register-btn" class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed" disabled>
Register
</button>
</form>
<div id="result" class="mt-4 text-center"></div>
<p class="text-center mt-4">
<a href="/login" class="text-blue-500 hover:underline">Already have an account? Login</a>
</p>
<div class="brand-gradient min-h-screen flex flex-col items-center justify-center p-4">
<div class="w-full max-w-sm">
<a href="/" class="flex items-center justify-center gap-2.5 mb-8">
<span class="text-3xl">📚</span>
<span class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
</a>
<h1 class="text-xl font-bold text-center mb-6" style="color: var(--text-primary)">Create your account</h1>
<form hx-post="/api/auth/register" hx-target="#result" hx-swap="innerHTML" class="card p-6 space-y-4">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email</label>
<input type="email" id="email" name="email" class="input" required/>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Username</label>
<input type="text" id="username" name="username" class="input" required/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">First Name</label>
<input type="text" name="first_name" placeholder="Optional" class="input"/>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Last Name</label>
<input type="text" name="last_name" placeholder="Optional" class="input"/>
</div>
</div>
<div id="password-requirements" class="p-4 rounded-xl text-sm" style="background-color: var(--bg-primary); border: 1px solid var(--border);">
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
<ul class="space-y-1" style="color: var(--text-secondary)">
<li id="req-length" class="flex items-center gap-2">
<span class="requirement-icon"></span> At least 8 characters
</li>
<li id="req-upper" class="flex items-center gap-2">
<span class="requirement-icon"></span> One uppercase letter
</li>
<li id="req-lower" class="flex items-center gap-2">
<span class="requirement-icon"></span> One lowercase letter
</li>
<li id="req-number" class="flex items-center gap-2">
<span class="requirement-icon"></span> One number
</li>
<li id="req-special" class="flex items-center gap-2">
<span class="requirement-icon"></span> One special character
</li>
<li id="req-match" class="flex items-center gap-2">
<span class="requirement-icon"></span> Passwords match
</li>
</ul>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label>
<input type="password" id="password" name="password" class="input" required/>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Confirm Password</label>
<input type="password" id="confirm-password" name="confirm_password" class="input" required/>
</div>
<button type="submit" id="register-btn" class="btn btn-primary w-full py-2.5 opacity-50 cursor-not-allowed" disabled>
Register
</button>
</form>
<div id="result" class="mt-4 text-center text-sm"></div>
<p class="text-center mt-6 text-sm" style="color: var(--text-secondary)">
Already have an account?
<a href="/login" class="font-medium hover:underline" style="color: var(--accent)">Login</a>
</p>
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -29,7 +29,7 @@ func Register() templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
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>Register</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night min-h-screen\" x-data=\"register\" x-init=\"initRegisterTheme()\"><div class=\"container mx-auto px-4 py-8 max-w-md\"><h1 class=\"text-3xl font-bold text-center mb-8\">Register for Bookhoard</h1><form hx-post=\"/api/auth/register\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 rounded-lg shadow-md border\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Email</label> <input type=\"email\" id=\"email\" name=\"email\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Username</label> <input type=\"text\" id=\"username\" name=\"username\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">First Name</label> <input type=\"text\" name=\"first_name\" placeholder=\"Optional\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Last Name</label> <input type=\"text\" name=\"last_name\" placeholder=\"Optional\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\"></div><div id=\"password-requirements\" class=\"mb-4 p-4 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><p class=\"font-semibold mb-2\" style=\"color: var(--text-primary)\">Password Requirements:</p><ul class=\"space-y-1\" style=\"color: var(--text-secondary)\"><li id=\"req-length\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> At least 8 characters</li><li id=\"req-upper\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> One uppercase letter</li><li id=\"req-lower\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> One lowercase letter</li><li id=\"req-number\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> One number</li><li id=\"req-special\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> One special character</li><li id=\"req-match\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> Passwords match</li></ul></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" id=\"password\" name=\"password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Confirm Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><button type=\"submit\" id=\"register-btn\" class=\"btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed\" disabled>Register</button></form><div id=\"result\" class=\"mt-4 text-center\"></div><p class=\"text-center mt-4\"><a href=\"/login\" class=\"text-blue-500 hover:underline\">Already have an account? Login</a></p></div></body></html>")
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>Register - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night min-h-screen\" x-data=\"register\" x-init=\"initRegisterTheme()\"><div class=\"brand-gradient min-h-screen flex flex-col items-center justify-center p-4\"><div class=\"w-full max-w-sm\"><a href=\"/\" class=\"flex items-center justify-center gap-2.5 mb-8\"><span class=\"text-3xl\">📚</span> <span class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></a><h1 class=\"text-xl font-bold text-center mb-6\" style=\"color: var(--text-primary)\">Create your account</h1><form hx-post=\"/api/auth/register\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email</label> <input type=\"email\" id=\"email\" name=\"email\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Username</label> <input type=\"text\" id=\"username\" name=\"username\" class=\"input\" required></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">First Name</label> <input type=\"text\" name=\"first_name\" placeholder=\"Optional\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Last Name</label> <input type=\"text\" name=\"last_name\" placeholder=\"Optional\" class=\"input\"></div></div><div id=\"password-requirements\" class=\"p-4 rounded-xl text-sm\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><p class=\"font-semibold mb-2\" style=\"color: var(--text-primary)\">Password Requirements:</p><ul class=\"space-y-1\" style=\"color: var(--text-secondary)\"><li id=\"req-length\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> At least 8 characters</li><li id=\"req-upper\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> One uppercase letter</li><li id=\"req-lower\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> One lowercase letter</li><li id=\"req-number\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> One number</li><li id=\"req-special\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> One special character</li><li id=\"req-match\" class=\"flex items-center gap-2\"><span class=\"requirement-icon\">○</span> Passwords match</li></ul></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" id=\"password\" name=\"password\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Confirm Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\" required></div><button type=\"submit\" id=\"register-btn\" class=\"btn btn-primary w-full py-2.5 opacity-50 cursor-not-allowed\" disabled>Register</button></form><div id=\"result\" class=\"mt-4 text-center text-sm\"></div><p class=\"text-center mt-6 text-sm\" style=\"color: var(--text-secondary)\">Already have an account? <a href=\"/login\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Login</a></p></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1,11 +1,18 @@
package templates
templ RestoreSystemCollectionModal() {
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Restore System Collection</h2>
<button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("refresh", "h-5 w-5")
</span>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Restore System Collection</h2>
</div>
<button type="button" @click="closeCollectionModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<div class="mb-4">
<p class="mb-4" style="color: var(--text-secondary)">Select a system collection to restore:</p>
@@ -13,7 +20,7 @@ templ RestoreSystemCollectionModal() {
hx-post="/api/dashboard/restore-system-collection"
hx-redirect="/collections"
>
<select name="collection_name" class="w-full px-4 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<select name="collection_name" class="input">
<option value="">Select collection...</option>
<option value="Continue Reading">Continue Reading</option>
<option value="Recently Added">Recently Added</option>
@@ -22,8 +29,8 @@ templ RestoreSystemCollectionModal() {
<option value="Continue Series">Continue Series</option>
</select>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Restore</button>
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Restore</button>
</div>
</form>
</div>
@@ -29,7 +29,23 @@ func RestoreSystemCollectionModal() templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Restore System Collection</h2><button type=\"button\" @click=\"closeCollectionModal()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div class=\"mb-4\"><p class=\"mb-4\" style=\"color: var(--text-secondary)\">Select a system collection to restore:</p><form hx-post=\"/api/dashboard/restore-system-collection\" hx-redirect=\"/collections\"><select name=\"collection_name\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select collection...</option> <option value=\"Continue Reading\">Continue Reading</option> <option value=\"Recently Added\">Recently Added</option> <option value=\"Recently Read\">Recently Read</option> <option value=\"Not Started\">Not Started</option> <option value=\"Continue Series\">Continue Series</option></select><div class=\"flex justify-end space-x-3 mt-6\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Restore</button></div></form></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "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, 2, "</span><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Restore System Collection</h2></div><button type=\"button\" @click=\"closeCollectionModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 3, "</button></div><div class=\"mb-4\"><p class=\"mb-4\" style=\"color: var(--text-secondary)\">Select a system collection to restore:</p><form hx-post=\"/api/dashboard/restore-system-collection\" hx-redirect=\"/collections\"><select name=\"collection_name\" class=\"input\"><option value=\"\">Select collection...</option> <option value=\"Continue Reading\">Continue Reading</option> <option value=\"Recently Added\">Recently Added</option> <option value=\"Recently Read\">Recently Read</option> <option value=\"Not Started\">Not Started</option> <option value=\"Continue Series\">Continue Series</option></select><div class=\"flex justify-end space-x-3 mt-6\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Restore</button></div></form></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+26 -18
View File
@@ -17,15 +17,24 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
<body x-data="seriesPage" x-init="initSeriesPage()" class="theme-{ user.Theme }">
@Header(user, "/series")
@LibrarySwitcher(libData, currentLibraryID)
<!-- Series Grid -->
<div id="series-container">
<main class="w-full px-4 py-8">
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6 flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("layers", "h-5 w-5")
</span>
<div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Series</h1>
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Grouped by series metadata</p>
</div>
</div>
if len(seriesList) == 0 {
<div id="series-empty" class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<div id="series-empty" class="card text-center py-16 px-6">
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("layers", "h-7 w-7")
</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Found</h3>
<p>Books with series metadata will appear here</p>
<p style="color: var(--text-secondary)">Books with series metadata will appear here</p>
</div>
}
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
@@ -33,28 +42,27 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
@SeriesCard(series)
}
</div>
<!-- Pagination -->
if totalPages > 1 {
<div id="series-pagination" class="flex justify-center items-center gap-4 mt-8">
<div id="series-pagination" class="flex justify-center items-center gap-3 mt-10">
if currentPage > 1 {
<a
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1) }
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
class="btn btn-secondary"
>
Previous
@Icon("chevron-left", "h-4 w-4")
<span>Previous</span>
</a>
}
<span class="text-sm" style="color: var(--text-secondary)">
<span class="chip">
Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) }
</span>
if currentPage < totalPages {
<a
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1) }
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
class="btn btn-secondary"
>
Next
<span>Next</span>
@Icon("chevron-right", "h-4 w-4")
</a>
}
</div>
@@ -68,7 +76,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
templ SeriesCard(series SeriesCardData) {
<a href={ "/series/detail?name=" + url.QueryEscape(series.Name) } class="block">
<div class="series-card rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow" style="background-color: var(--bg-secondary);">
<div class="series-card card rounded-2xl overflow-hidden cursor-pointer" style="box-shadow: var(--shadow-card);">
<div class={ "stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths)) }>
for i, cover := range series.CoverPaths {
<img
@@ -80,8 +88,8 @@ templ SeriesCard(series SeriesCardData) {
/>
}
if len(series.CoverPaths) == 0 {
<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary);">
<span class="text-3xl">📚</span>
<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary); color: var(--text-secondary);">
@Icon("book", "h-8 w-8 opacity-60")
</div>
}
</div>
+78 -38
View File
@@ -46,17 +46,33 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Series Grid --><div id=\"series-container\"><main class=\"w-full px-4 py-8\"><h1 class=\"text-3xl font-bold mb-6\" style=\"color: var(--text-primary)\">Series</h1>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"series-container\"><main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("layers", "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, 3, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Series</h1><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Grouped by series metadata</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(seriesList) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div id=\"series-empty\" class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📚</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Found</h3><p>Books with series metadata will appear here</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div id=\"series-empty\" class=\"card text-center py-16 px-6\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("layers", "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, 5, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Series Found</h3><p style=\"color: var(--text-secondary)\">Books with series metadata will appear here</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -66,89 +82,105 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><!-- Pagination -->")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if totalPages > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-4 mt-8\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-3 mt-10\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentPage > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 41, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 49, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">← Previous</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span>Previous</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"chip\">Page ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 49, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 57, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " of ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 49, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 57, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentPage < totalPages {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 53, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 61, Col: 93}
}
_, 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, 13, "\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">Next →</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"btn btn-secondary\"><span>Next</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</main></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</main></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -156,7 +188,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -185,20 +217,20 @@ func SeriesCard(series SeriesCardData) templ.Component {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 70, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 78, Col: 64}
}
_, 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, 18, "\" class=\"block\"><div class=\"series-card rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" class=\"block\"><div class=\"series-card card rounded-2xl overflow-hidden cursor-pointer\" style=\"box-shadow: var(--shadow-card);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -207,7 +239,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -220,7 +252,7 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -230,20 +262,20 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<img src=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 75, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 83, Col: 17}
}
_, 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, 22, "\" class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -256,70 +288,78 @@ func SeriesCard(series SeriesCardData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 77, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 85, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\"> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(series.CoverPaths) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary);\"><span class=\"text-3xl\">📚</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary); color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "h-8 w-8 opacity-60").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 89, Col: 99}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 97, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 91, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 99, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " of ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 91, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 99, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " books</p></div></div></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " books</p></div></div></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+113 -95
View File
@@ -12,21 +12,24 @@ templ Setup() {
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-tokyo-night min-h-screen" x-data="setupWizard" x-init="initSetup()">
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="mx-auto container px-4 py-8 max-w-2xl">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">Welcome to Bookhoard</h1>
<span class="inline-grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("library", "h-7 w-7")
</span>
<h1 class="text-4xl font-bold mb-2 tracking-tight" style="color: var(--text-primary)">Welcome to Bookhoard</h1>
<p class="text-lg" style="color: var(--text-secondary)">Let's get your library set up.</p>
</div>
<!-- Step indicator -->
<div class="flex items-center justify-center mb-8 gap-2">
<template x-for="n in 4" :key="n">
<div class="flex items-center gap-2">
<div
class="w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-all"
class="w-10 h-10 rounded-full grid place-items-center font-bold text-sm transition-all"
:class="currentStep >= n ? 'bg-[var(--accent)] text-[var(--bg-primary)]' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border)]'"
>
<span x-text="currentStep > n ? '✓' : n"></span>
<template x-if="currentStep > n">@Icon("check", "h-5 w-5")</template>
<span x-show="currentStep <= n" x-text="n"></span>
</div>
<div
x-text="['Admin', 'Libraries', 'Folders', 'Scan'][n-1]"
@@ -38,76 +41,73 @@ templ Setup() {
</template>
</div>
<!-- Error display -->
<div x-show="errorMsg" class="mb-4 p-4 rounded-lg border border-red-500/50 bg-red-500/10 text-red-400 text-sm">
<div x-show="errorMsg" class="mb-4 card p-4 text-sm" style="border-color: var(--status-danger); background-color: color-mix(in srgb, var(--status-danger) 12%, transparent); color: var(--status-danger);">
<span x-text="errorMsg"></span>
</div>
<!-- ==================== STEP 1: Create Admin ==================== -->
<div x-show="currentStep === 1" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div x-show="currentStep === 1" class="card p-6">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Create Administrator Account</h2>
<p class="mb-6" style="color: var(--text-secondary)">This first account will have full admin privileges.</p>
<form @submit.prevent="submitAdmin()">
<div class="mb-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Server URL (Base URL)</label>
<div class="mb-6 card p-4" style="background-color: var(--bg-primary);">
<label for="base-url" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Server URL (Base URL)</label>
<input
type="url"
id="base-url"
x-model="admin.baseUrl"
placeholder="https://books.example.com"
class="w-full px-3 py-2 border rounded mt-2"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
required
/>
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance. Used for device sync, OPDS feed, and API endpoints.</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<label for="email" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input
type="email"
id="email"
x-model="admin.email"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
required
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<label for="username" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input
type="text"
x-model="admin.username"
id="username"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
required
/>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<label for="first-name" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input
type="text"
id="first-name"
x-model="admin.firstName"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<label for="last-name" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input
type="text"
id="last-name"
x-model="admin.lastName"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
/>
</div>
</div>
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-primary); border-color: var(--border)">
<div id="password-requirements" class="mb-4 card p-4 text-sm" style="background-color: var(--bg-primary);">
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
<ul class="space-y-1" style="color: var(--text-secondary)">
<li id="req-length" class="flex items-center gap-2">
@@ -131,48 +131,49 @@ templ Setup() {
</ul>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<label for="password" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input
type="password"
x-model="admin.password"
id="password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
required
/>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
<label for="confirm-password" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
<input
type="password"
x-model="admin.confirmPassword"
id="confirm-password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
required
/>
</div>
<button
type="submit"
id="register-btn"
class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed"
class="btn btn-primary w-full"
:disabled="loading"
x-text="loading ? 'Creating...' : 'Create Admin Account'"
x-text="loading ? 'Creating' : 'Create Admin Account'"
></button>
</form>
</div>
<!-- ==================== STEP 2: Create Libraries ==================== -->
<div x-show="currentStep === 2" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div x-show="currentStep === 2" class="card p-6">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Create Libraries</h2>
<p class="mb-6" style="color: var(--text-secondary)">Add one or more media libraries. You can always add more later.</p>
<!-- Created libraries list -->
<div x-show="libraries.length > 0" class="mb-6 space-y-2">
<template x-for="(lib, idx) in libraries" :key="lib.id">
<div class="p-3 rounded-lg border flex items-center justify-between" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="card p-3 flex items-center justify-between" style="background-color: var(--bg-primary);">
<div class="flex items-center gap-3">
<span class="text-xl" x-text="lib.type === 'ebooks' ? '📚' : lib.type === 'comics' ? '📖' : '🗾'"></span>
<span class="grid place-items-center h-9 w-9 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
<span class="inline-flex" x-show="lib.type === 'ebooks'">@Icon("book", "h-5 w-5")</span>
<span class="inline-flex" x-show="lib.type === 'comics'">@Icon("book-open", "h-5 w-5")</span>
<span class="inline-flex" x-show="lib.type === 'manga'">@Icon("library", "h-5 w-5")</span>
</span>
<div>
<p class="font-medium" style="color: var(--text-primary)" x-text="lib.name"></p>
<p class="text-xs capitalize" style="color: var(--text-secondary)" x-text="lib.type"></p>
@@ -181,65 +182,70 @@ templ Setup() {
<button
type="button"
@click="removeLibrary(idx)"
class="text-xs text-red-500 hover:underline"
>Remove</button>
class="icon-btn"
style="color: var(--status-danger);"
aria-label="Remove library"
>@Icon("trash", "h-4 w-4")</button>
</div>
</template>
</div>
<!-- Add library form -->
<form @submit.prevent="addLibrary()" class="space-y-4 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<form @submit.prevent="addLibrary()" class="space-y-4 card p-4" style="background-color: var(--bg-primary);">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library Name</label>
<label for="lib-name" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Library Name</label>
<input
type="text"
id="lib-name"
x-model="newLibrary.name"
placeholder="My Ebook Library"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
required
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library Type</label>
<label for="lib-type" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Library Type</label>
<select
id="lib-type"
x-model="newLibrary.type"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
>
<option value="ebooks">📚 Ebooks</option>
<option value="comics">📖 Comics</option>
<option value="manga">🗾 Manga</option>
<option value="ebooks">Ebooks</option>
<option value="comics">Comics</option>
<option value="manga">Manga</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Description (optional)</label>
<label for="lib-desc" class="block text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Description (optional)</label>
<input
type="text"
id="lib-desc"
x-model="newLibrary.description"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
class="input mt-2"
/>
</div>
<button
type="submit"
class="btn-secondary w-full py-2 rounded"
class="btn btn-secondary w-full"
:disabled="loading || !newLibrary.name.trim()"
x-text="loading ? 'Creating...' : '+ Add This Library'"
x-text="loading ? 'Creating' : '+ Add This Library'"
></button>
</form>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 1" class="btn-secondary px-4 py-2 rounded">Back</button>
<button type="button" @click="currentStep = 3" class="btn-primary px-6 py-2 rounded">
<button type="button" @click="currentStep = 1" class="btn btn-secondary">
@Icon("arrow-left", "h-4 w-4")
Back
</button>
<button type="button" @click="currentStep = 3" class="btn btn-primary">
<span x-text="libraries.length > 0 ? 'Next: Configure Folders' : 'Skip Continue'"></span>
@Icon("arrow-right", "h-4 w-4")
</button>
</div>
</div>
<!-- ==================== STEP 3: Configure Folders ==================== -->
<div x-show="currentStep === 3" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div x-show="currentStep === 3" class="card p-6">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Configure Library Folders</h2>
<p class="mb-6" style="color: var(--text-secondary)">Add the filesystem folders where your media files are stored.</p>
@@ -249,40 +255,44 @@ templ Setup() {
<div class="space-y-6">
<template x-for="lib in libraries" :key="lib.id">
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="card p-4" style="background-color: var(--bg-primary);">
<div class="flex items-center gap-2 mb-3">
<span class="text-xl" x-text="lib.type === 'ebooks' ? '📚' : lib.type === 'comics' ? '📖' : '🗾'"></span>
<span class="grid place-items-center h-8 w-8 rounded-lg" style="background-color: var(--accent-muted); color: var(--accent);">
<span class="inline-flex" x-show="lib.type === 'ebooks'">@Icon("book", "h-4 w-4")</span>
<span class="inline-flex" x-show="lib.type === 'comics'">@Icon("book-open", "h-4 w-4")</span>
<span class="inline-flex" x-show="lib.type === 'manga'">@Icon("library", "h-4 w-4")</span>
</span>
<h4 class="font-semibold" style="color: var(--text-primary)" x-text="lib.name"></h4>
</div>
<!-- Existing folders for this library -->
<div class="space-y-1 mb-3" x-show="lib.folders && lib.folders.length > 0">
<template x-for="(folder, fIdx) in (lib.folders || [])" :key="folder">
<div class="flex items-center justify-between p-2 rounded text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
<span style="color: var(--text-primary)" x-text="folder"></span>
<button type="button" @click="removeFolder(lib.id, folder, fIdx)" class="text-xs text-red-500 hover:underline">Remove</button>
<div class="flex items-center justify-between p-2 rounded-lg text-sm" style="background-color: var(--bg-secondary);">
<span class="flex items-center gap-2 truncate" style="color: var(--text-primary)">
@Icon("folder", "h-4 w-4 shrink-0")
<span class="truncate" x-text="folder"></span>
</span>
<button type="button" @click="removeFolder(lib.id, folder, fIdx)" class="icon-btn shrink-0" style="color: var(--status-danger);" aria-label="Remove folder">@Icon("trash", "h-4 w-4")</button>
</div>
</template>
</div>
<!-- Add folder -->
<div class="flex gap-2">
<input
type="text"
:placeholder="'/path/to/' + lib.type"
:id="'folderInput-' + lib.id"
class="flex-1 px-3 py-2 border rounded text-sm"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
class="input flex-1 text-sm"
/>
<button
type="button"
@click="showFolderBrowser('folderInput-' + lib.id)"
class="btn-secondary px-3 py-2 text-xs rounded"
class="btn btn-secondary px-3 text-xs"
>Browse</button>
<button
type="button"
@click="addFolderToLibrary(lib)"
class="btn-primary px-3 py-2 text-xs rounded"
class="btn btn-primary px-3 text-xs"
>Add</button>
</div>
</div>
@@ -290,35 +300,40 @@ templ Setup() {
</div>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 2" class="btn-secondary px-4 py-2 rounded">Back</button>
<button type="button" @click="currentStep = 4" class="btn-primary px-6 py-2 rounded">Next: Scan</button>
<button type="button" @click="currentStep = 2" class="btn btn-secondary">
@Icon("arrow-left", "h-4 w-4")
Back
</button>
<button type="button" @click="currentStep = 4" class="btn btn-primary">
Next: Scan
@Icon("arrow-right", "h-4 w-4")
</button>
</div>
</div>
<!-- ==================== STEP 4: Scan & Finish ==================== -->
<div x-show="currentStep === 4" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div x-show="currentStep === 4" class="card p-6">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Scan & Finish</h2>
<p class="mb-6" style="color: var(--text-secondary)">Review your configuration and start the initial scan.</p>
<!-- Summary -->
<div class="space-y-3 mb-6">
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="text-sm font-medium mb-1" style="color: var(--text-secondary)">Server URL</p>
<div class="card p-4" style="background-color: var(--bg-primary);">
<p class="text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Server URL</p>
<p style="color: var(--text-primary)" x-text="admin.baseUrl"></p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="text-sm font-medium mb-1" style="color: var(--text-secondary)">Administrator</p>
<div class="card p-4" style="background-color: var(--bg-primary);">
<p class="text-xs uppercase tracking-wide font-medium mb-1" style="color: var(--text-secondary)">Administrator</p>
<p style="color: var(--text-primary)" x-text="admin.username + ' (' + admin.email + ')'"></p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="text-sm font-medium mb-2" style="color: var(--text-secondary)">
<div class="card p-4" style="background-color: var(--bg-primary);">
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">
Libraries (<span x-text="libraries.length"></span>)
</p>
<template x-for="lib in libraries" :key="lib.id">
<div class="flex items-start justify-between text-sm py-1">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--text-primary)" x-text="lib.name"></span>
<span class="ml-2 text-xs px-2 py-0.5 rounded" style="background-color: var(--accent); color: var(--bg-primary)" x-text="lib.type"></span>
<span class="chip" x-text="lib.type"></span>
</div>
<div class="text-right" style="color: var(--text-secondary)">
<span x-text="(lib.folders || []).length + ' folder(s)'"></span>
@@ -328,12 +343,11 @@ templ Setup() {
</div>
</div>
<!-- Scan progress -->
<div x-show="scanStarted" class="mb-6 space-y-3">
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Progress</h3>
<div id="library-progress-list" class="space-y-3">
<template x-for="job in scanJobs" :key="job.jobId">
<div class="p-3 rounded border" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="card p-3" style="background-color: var(--bg-primary);">
<div class="flex justify-between items-center mb-2">
<span class="font-medium" style="color: var(--text-primary)" x-text="job.libraryName"></span>
<span class="text-sm" style="color: var(--text-secondary)" x-text="job.statusText"></span>
@@ -347,28 +361,34 @@ templ Setup() {
</div>
</template>
</div>
<div x-show="scanComplete" class="p-4 rounded-lg border border-green-500/50 bg-green-500/10 text-green-400 text-sm">
<div x-show="scanComplete" class="card p-4 text-sm" style="border-color: var(--status-success); background-color: color-mix(in srgb, var(--status-success) 12%, transparent); color: var(--status-success);">
Initial scan complete! Your libraries are ready to use.
</div>
</div>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 3" class="btn-secondary px-4 py-2 rounded" :disabled="loading">Back</button>
<button type="button" @click="currentStep = 3" class="btn btn-secondary" :disabled="loading">
@Icon("arrow-left", "h-4 w-4")
Back
</button>
<div class="flex gap-3">
<button
x-show="!scanStarted"
type="button"
@click="startScan()"
class="btn-primary px-6 py-2 rounded"
class="btn btn-primary"
:disabled="loading"
>Start Scan</button>
>
@Icon("sync", "h-4 w-4")
Start Scan
</button>
<button
x-show="scanStarted && !scanComplete"
type="button"
@click="finishSetup()"
class="btn-secondary px-6 py-2 rounded"
class="btn btn-secondary"
:disabled="loading"
>Skip to Dashboard</button>
@@ -376,24 +396,22 @@ templ Setup() {
x-show="scanComplete"
type="button"
@click="finishSetup()"
class="btn-primary px-6 py-2 rounded"
class="btn btn-primary"
:disabled="loading"
x-text="loading ? 'Finishing...' : 'Go to Dashboard'"
x-text="loading ? 'Finishing' : 'Go to Dashboard'"
></button>
</div>
</div>
</div>
</div>
<!-- Folder Browser Modal (reused from admin library) -->
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7)">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
<button type="button" @click="hideFolderBrowser()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<button type="button" @click="hideFolderBrowser()" class="icon-btn" aria-label="Close">@Icon("close", "h-5 w-5")</button>
</div>
<div id="folder-browser-content">
<!-- Directory listings rendered by Alpine -->
</div>
</div>
</div>
File diff suppressed because one or more lines are too long
+37
View File
@@ -0,0 +1,37 @@
package templates
// ThemeOption describes a selectable color theme shown in the header picker.
type ThemeOption struct {
Name string // CSS theme suffix applied as class theme-<name>
Label string // Human-readable label
Color string // Hex swatch colour
}
// ThemeOptions is the full set of selectable colour themes, in display order.
var ThemeOptions = []ThemeOption{
{"tokyo-night", "Tokyo Night", "#7aa2f7"},
{"dracula", "Dracula", "#bd93f9"},
{"nord", "Nord", "#88c0d0"},
{"solarized-dark", "Solarized Dark", "#2aa198"},
{"monokai", "Monokai", "#a6e22e"},
{"one-dark-pro", "One Dark Pro", "#61dafb"},
{"material-dark", "Material Dark", "#80cbc4"},
{"catppuccin-mocha", "Catppuccin Mocha", "#f38ba8"},
{"catppuccin-macchiato", "Catppuccin Macchiato", "#c6a0f6"},
{"catppuccin-frappe", "Catppuccin Frappé", "#ca9ee6"},
{"catppuccin-latte", "Catppuccin Latte", "#d20f39"},
}
// WoodOption describes a selectable bookshelf background texture.
type WoodOption struct {
Name string // value stored as data-wood and CSS bg-<name>
Label string
}
// WoodOptions is the full set of bookshelf background options.
var WoodOptions = []WoodOption{
{"none", "None"},
{"wood-light", "Wood Light"},
{"wood-dark", "Wood Dark"},
{"wood-mahogany", "Wood Mahogany"},
}
+1
View File
@@ -30,6 +30,7 @@ type CollectionData struct {
Description string
Color string
Icon string
IsSystem bool
}
type LibraryData struct {
+73 -63
View File
@@ -12,107 +12,118 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</head>
<body class="theme-{ user.Theme }" x-data="unlinkedBooks" x-init="setupEventDelegation()">
@Header(user, "/unlinked")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Unlinked Books</h1>
<p style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("sync", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Unlinked Books</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
</div>
if len(unlinkedBooks) > 0 {
<div class="flex justify-between items-center mb-6 p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card p-4 mb-6 flex flex-wrap justify-between items-center gap-3">
<div class="flex items-center gap-4">
<input type="checkbox" id="select-all-unlinked" @change="toggleAllUnlinked()" class="w-5 h-5"/>
<label for="select-all-unlinked" class="cursor-pointer" style="color: var(--text-primary)">Select All</label>
<label for="select-all-unlinked" class="cursor-pointer text-sm font-medium" style="color: var(--text-primary)">Select All</label>
<span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
</div>
<div class="flex gap-2">
<button @click="bulkAutoLink()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
🤖 Auto-Link Selected (High Confidence)
<div class="flex gap-2 flex-wrap">
<button @click="bulkAutoLink()" class="btn btn-secondary text-sm">
@Icon("sync", "h-4 w-4")
Auto-Link Selected (High Confidence)
</button>
<button @click="bulkGetSuggestions()" class="btn-secondary px-4 py-2 rounded-lg text-sm">
💡 Get Suggestions for Selected
<button @click="bulkGetSuggestions()" class="btn btn-secondary text-sm">
@Icon("search", "h-4 w-4")
Get Suggestions for Selected
</button>
<button @click="showBulkManualLink()" class="btn-primary px-4 py-2 rounded-lg text-sm">
🔗 Bulk Manual Link
<button @click="showBulkManualLink()" class="btn btn-primary text-sm">
@Icon("edit", "h-4 w-4")
Bulk Manual Link
</button>
</div>
</div>
}
<div id="unlinked-container" class="space-y-6">
if len(unlinkedBooks) == 0 {
<div class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4"></div>
<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);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">All Books Linked!</h3>
<p>There are no unlinked books that need manual resolution.</p>
<p style="color: var(--text-secondary)">There are no unlinked books that need manual resolution.</p>
</div>
}
for _, book := range unlinkedBooks {
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="card p-6">
<div class="flex gap-6">
<div class="flex items-start pt-2">
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id="{ book.ProgressID }" data-title="{ book.TitleFromDevice }" onchange="updateSelectedCount()"/>
</div>
<div class="flex-1">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-4">
<div class="text-4xl">
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
if book.DeviceType == "koreader" {
📖
@Icon("book-open", "h-5 w-5")
} else if book.DeviceType == "kobo" {
📚
@Icon("library", "h-5 w-5")
} else {
📱
@Icon("device", "h-5 w-5")
}
</div>
<div>
<h3 class="font-semibold text-lg" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
</span>
<div class="min-w-0">
<h3 class="font-semibold text-lg truncate" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
<p class="text-sm" style="color: var(--text-secondary)">
Device: { book.DeviceName } ({ book.DeviceType })
</p>
</div>
</div>
<div class="space-y-2 text-sm mb-4">
<div>
<span style="color: var(--text-secondary)">File Path:</span>
<code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px;">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">File Path:</span>
<code class="chip font-mono">
@Icon("folder", "h-3.5 w-3.5")
{ book.FilePath }
</code>
</div>
if book.SHA256 != "" {
<div>
<span style="color: var(--text-secondary)">SHA-256:</span>
<code style="color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px; font-size: 11px;">
{ book.SHA256 }
</code>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">SHA-256:</span>
<code class="chip font-mono text-xs" title={ book.SHA256 }>{ book.SHA256 }</code>
</div>
}
<div>
<span style="color: var(--text-secondary)">Last Synced:</span>
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Last Synced:</span>
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
</div>
if book.ConfidenceScore > 0 {
<div>
<span style="color: var(--text-secondary)">Auto-Link Confidence:</span>
<span style="color: var(--text-primary)">
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Auto-Link Confidence:</span>
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">
{ book.ConfidenceScore }% confidence
</span>
</div>
}
</div>
<div class="mb-4">
<div class="mb-4 flex gap-2 flex-wrap">
<button
@click="searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')"
class="btn-secondary px-4 py-2 rounded-lg text-sm"
class="btn btn-secondary text-sm"
>
🔍 Find Potential Matches
@Icon("search", "h-4 w-4")
Find Potential Matches
</button>
<button
@click="showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')"
class="btn-primary px-4 py-2 rounded-lg text-sm"
class="btn btn-primary text-sm"
>
🔗 Manually Link
@Icon("edit", "h-4 w-4")
Manually Link
</button>
</div>
<div id="matches-{ book.ProgressID }" class="hidden">
<div class="border-t pt-4" style="border-color: var(--border);">
<div class="border-t pt-4 mt-2" style="border-color: var(--border);">
<h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4>
<div id="matches-list-{ book.ProgressID }" class="space-y-3">
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
@@ -124,50 +135,49 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</div>
}
</div>
</div>
<!-- Manual Link Modal -->
<div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
</main>
<div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
<button @click="hideManualLinkModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
<button @click="hideManualLinkModal()" class="icon-btn" aria-label="Close">@Icon("close", "h-5 w-5")</button>
</div>
<input type="hidden" id="link-progress-id"/>
<input type="hidden" id="link-book-sha256"/>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
<label for="link-book-title" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
<input
type="text"
id="link-book-title"
readonly
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);"
class="input"
style="color: var(--text-secondary);"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
<label for="link-search-input" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
<div class="flex gap-2">
<input
type="text"
id="link-search-input"
placeholder="Search by title, author..."
class="flex-1 px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search by title, author"
class="input flex-1"
/>
<button
type="button"
@click="searchBooksForLink()"
class="btn-primary px-4 py-2 rounded-lg"
class="btn btn-primary"
>
@Icon("search", "h-4 w-4")
Search
</button>
</div>
</div>
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
<p style="color: var(--text-secondary)">Search for books to link</p>
<p class="text-sm" style="color: var(--text-secondary)">Search for books to link</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
<label for="link-confidence" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
<input
type="number"
id="link-confidence"
@@ -175,16 +185,16 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
max="1"
step="0.1"
value="1.0"
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
class="input"
/>
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="hideManualLinkModal()" class="btn-secondary px-4 py-2 rounded-lg">
<div class="flex justify-end gap-3">
<button type="button" @click="hideManualLinkModal()" class="btn btn-secondary">
Cancel
</button>
<button type="button" @click="confirmManualLink()" class="btn-primary px-4 py-2 rounded-lg">
<button type="button" @click="confirmManualLink()" class="btn btn-primary">
@Icon("check", "h-4 w-4")
Link Book
</button>
</div>
+133 -36
View File
@@ -37,164 +37,261 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">Unlinked Books</h1><p style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("sync", "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, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(unlinkedBooks) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"flex justify-between items-center mb-6 p-4 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2\"><button @click=\"bulkAutoLink()\" class=\"btn-secondary px-4 py-2 rounded-lg text-sm\">🤖 Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn-secondary px-4 py-2 rounded-lg text-sm\">💡 Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn-primary px-4 py-2 rounded-lg text-sm\">🔗 Bulk Manual Link</button></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("sync", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Bulk Manual Link</button></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div id=\"unlinked-container\" class=\"space-y-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"unlinked-container\" class=\"space-y-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(unlinkedBooks) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">✓</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p>There are no unlinked books that need manual resolution.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<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 {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check-circle", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, book := range unlinkedBooks {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex gap-6\"><div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"{ book.ProgressID }\" data-title=\"{ book.TitleFromDevice }\" onchange=\"updateSelectedCount()\"></div><div class=\"flex-1\"><div class=\"flex items-center gap-3 mb-4\"><div class=\"text-4xl\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-6\"><div class=\"flex gap-6\"><div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"{ book.ProgressID }\" data-title=\"{ book.TitleFromDevice }\" onchange=\"updateSelectedCount()\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.DeviceType == "koreader" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "📖")
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if book.DeviceType == "kobo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "📚")
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "📱")
templ_7745c5c3_Err = Icon("device", "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, 10, "</div><div><h3 class=\"font-semibold text-lg\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 66, Col: 102}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 76, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 68, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 78, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 68, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 78, Col: 58}
}
_, 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, 13, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div><span style=\"color: var(--text-secondary)\">File Path:</span> <code style=\"color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("folder", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 76, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 87, Col: 27}
}
_, 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, 14, "</code></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.SHA256 != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div><span style=\"color: var(--text-secondary)\">SHA-256:</span> <code style=\"color: var(--text-primary); background-color: var(--bg-primary); padding: 2px 6px; border-radius: 4px; font-size: 11px;\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 83, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 93, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
_, 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, 16, "</code></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 93, Col: 84}
}
_, 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, 19, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div><span style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 89, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 98, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
_, 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, 18, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.ConfidenceScore > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div><span style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 95, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 104, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
_, 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, 20, "% confidence</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "% confidence</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"mb-4\"><button @click=\"searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')\" class=\"btn-secondary px-4 py-2 rounded-lg text-sm\">🔍 Find Potential Matches</button> <button @click=\"showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')\" class=\"btn-primary px-4 py-2 rounded-lg text-sm\">🔗 Manually Link</button></div><div id=\"matches-{ book.ProgressID }\" class=\"hidden\"><div class=\"border-t pt-4\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"matches-list-{ book.ProgressID }\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"searchMatches('{ book.ProgressID }', '{ book.SHA256 }', '{ book.TitleFromDevice }')\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Find Potential Matches</button> <button @click=\"showManualLinkModal('{ book.ProgressID }', '{ book.TitleFromDevice }')\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Manually Link</button></div><div id=\"matches-{ book.ProgressID }\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"matches-list-{ book.ProgressID }\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div></div><!-- Manual Link Modal --><div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-secondary); border-color: var(--border);\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author...\" class=\"flex-1 px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn-primary px-4 py-2 rounded-lg\">Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn-primary px-4 py-2 rounded-lg\">Link Book</button></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div></main><div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 28, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Link Book</button></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+14 -5
View File
@@ -15,11 +15,20 @@ import (
)
func activeClass(current, target string) string {
base := "block px-4 py-2 rounded-lg "
base := "flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors "
if current == target {
return base + "bg-accent text-bg-primary"
return base + "bg-brand/15 text-brand"
}
return base + "hover:opacity-80"
return base + "text-content-muted hover:bg-surface-hover hover:text-content"
}
// navItemClass returns classes for top-nav links, marking the active route.
func navItemClass(current, target string) string {
base := "text-sm font-medium px-3 py-2 rounded-lg transition-colors "
if current == target {
return base + "text-content bg-surface-hover"
}
return base + "text-content-muted hover:text-content hover:bg-surface-hover"
}
func ContainsString(slice []string, item string) bool {
@@ -66,13 +75,13 @@ func renderStars(rating int32) string {
for i := int32(0); i < totalStars; i++ {
if i < fullStars {
// Filled star - accent color (theme-aware highlight)
stars.WriteString(`<span style="color: var(--accent);">★</span>`)
stars.WriteString(`<span class="text-brand">★</span>`)
} else if i == fullStars && hasHalf {
// Half star - gradient from accent (left) to grey (right)
stars.WriteString(`<span style="background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">★</span>`)
} else {
// Empty star - grey using theme variable
stars.WriteString(`<span style="color: var(--text-secondary);">★</span>`)
stars.WriteString(`<span class="text-content-muted/40">★</span>`)
}
}
+7 -10
View File
@@ -48,18 +48,16 @@ Alpine.store("bookPicker", {
},
clearFilters() {
const filterForm = document.getElementById(
"book-picker-filters",
) as HTMLFormElement;
if (!filterForm) return;
const filterDiv = document.getElementById("book-picker-filters");
if (!filterDiv) return;
// Reset all form fields except checkbox state (that's in Alpine.store)
const inputs = filterForm.querySelectorAll("input:not([type='checkbox'])");
const inputs = filterDiv.querySelectorAll(
'input[type="text"]',
) as NodeListOf<HTMLInputElement>;
inputs.forEach((input) => {
(input as HTMLInputElement).value = "";
input.value = "";
});
// Reload books - selection state preserved in Alpine.store
this.loadBooks();
},
@@ -97,8 +95,7 @@ Alpine.store("bookPicker", {
"success",
);
this.close();
// Reload collection detail page
window.htmx.trigger(document.body, "reloadCollection");
location.reload();
} else {
showToast("Failed to add books", "error");
}
+1
View File
@@ -80,6 +80,7 @@ Alpine.data("bookshelf", () => ({
// Component state
showSaveModal: false,
showFiltersDropdown: false,
filtersOpen: false,
hasCoverState: null as boolean | null,
dropdownAlign: "right" as "left" | "right",
resizeTimeout: null as number | null,
+152 -21
View File
@@ -127,6 +127,9 @@ 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";
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;
@@ -135,29 +138,37 @@ function renderCollectionBooks(books: BookInfo[]): void {
container.innerHTML = books
.map(
(book) => `
<a href="/media/${book.media_item_id}">
<div class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex gap-4">
<div class="flex-shrink-0 pt-1">
<input type="checkbox" onchange="toggleBookForRemoval('${book.media_item_id}')" class="w-5 h-5"/>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-lg mb-1 line-clamp-2" style="color: var(--text-primary)">${book.title}</h3>
${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">
<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">
${isSystem ? "" : `
<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'"/>
</div>
</div>
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button @click="removeBook('${book.media_item_id}')" class="px-3 py-1 text-sm border rounded hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary);">🗑 Remove from Collection</button>
</a>
</div>
</div>
</a>
${isSystem ? "" : `
<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("");
@@ -352,7 +363,7 @@ function selectColor(color: string): void {
(btn as HTMLElement).style.outlineOffset = "0";
});
const selectedBtn = document.querySelector(
`.color-option[onclick="selectColor('${color}')"]`,
`.color-option[data-color="${color}"]`,
) as HTMLElement;
if (selectedBtn) {
selectedBtn.style.outline = "3px solid var(--text-primary)";
@@ -391,6 +402,10 @@ function setupHTMXModalInit(): void {
const target = evt.detail.target;
if (target && target.id === "modal-container") {
populateIconGrid();
initColorSelection();
Alpine.initTree(target);
}
if (target && target.id === "book-picker-grid") {
Alpine.initTree(target);
}
});
@@ -459,8 +474,9 @@ function populateIconGrid(): void {
button.type = "button";
button.className = "icon-btn text-2xl p-1 hover:bg-opacity-80 rounded";
button.textContent = emoji;
button.setAttribute("onclick", `selectIcon('${emoji}')`);
button.setAttribute("data-emoji", emoji);
button.setAttribute("data-keywords", keywords.join(","));
button.addEventListener("click", () => selectIcon(emoji));
iconGrid.appendChild(button);
});
}
@@ -482,7 +498,7 @@ function selectIcon(icon: string): void {
});
const selectedBtn = document.querySelector(
`.icon-btn[onclick="selectIcon('${icon}')"]`,
`.icon-btn[data-emoji="${icon}"]`,
) as HTMLElement;
if (selectedBtn) {
selectedBtn.style.outline = "2px solid var(--text-primary)";
@@ -553,10 +569,90 @@ function initCollectionsPage(): void {
setupHTMXModalInit();
}
function getCollectionId(): string {
const dataEl = document.getElementById("collection-data");
return dataEl?.dataset.id || "";
}
function toggleSelection(this: any, id: string): void {
const idx = this.selectedBooks.indexOf(id);
if (idx >= 0) {
this.selectedBooks.splice(idx, 1);
} else {
this.selectedBooks.push(id);
}
}
async function doRemoveBook(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(
`/api/collections/${getCollectionId()}/books/${id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
if (response.ok) {
showToast("Removed from collection", "success");
setTimeout(() => location.reload(), 500);
} else {
showToast("Failed to remove book", "error");
}
} catch {
showToast("Error removing book", "error");
}
}
async function doBulkRemove(ids: string[]): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(
`/api/collections/${getCollectionId()}/books/bulk-remove`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ book_ids: ids }),
},
);
if (response.ok) {
showToast(`Removed ${ids.length} book(s)`, "success");
setTimeout(() => location.reload(), 500);
} else {
showToast("Failed to remove books", "error");
}
} catch {
showToast("Error removing books", "error");
}
}
function filterCollectionBooks(): void {
const input = document.getElementById(
"collection-search",
) as HTMLInputElement;
if (!input) return;
const query = input.value.toLowerCase();
const cards = document.querySelectorAll<HTMLElement>(
".collection-book-card",
);
cards.forEach((card) => {
const title = (card.dataset.title || "").toLowerCase();
const author = (card.dataset.author || "").toLowerCase();
card.style.display =
title.includes(query) || author.includes(query) ? "" : "none";
});
}
export {
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
initColorSelection,
initializeCollectionWebSocket,
@@ -571,12 +667,14 @@ export {
setupHTMXModalInit,
showAllIcons,
testRule,
toggleSelection,
};
Alpine.data("collections", () => ({
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
initColorSelection,
initializeCollectionWebSocket,
@@ -587,7 +685,40 @@ Alpine.data("collections", () => ({
populateIconGrid,
selectColor,
selectIcon,
selectedBooks: [] as string[],
setupHTMXModalInit,
showAllIcons,
testRule,
toggleSelection,
showConfirm: false,
confirmMessage: "",
confirmLabel: "Remove",
pendingAction: null as null | (() => void),
requestRemoveBook(this: any, id: string) {
this.confirmMessage = "Remove this book from the collection?";
this.pendingAction = () => doRemoveBook(id);
this.showConfirm = true;
},
requestBulkRemove(this: any) {
if (this.selectedBooks.length === 0) return;
this.confirmMessage = `Remove ${this.selectedBooks.length} book(s) from this collection?`;
const ids = [...this.selectedBooks];
this.pendingAction = () => doBulkRemove(ids);
this.showConfirm = true;
},
executeConfirmed(this: any) {
this.showConfirm = false;
const action = this.pendingAction;
this.pendingAction = null;
if (action) action();
},
closeConfirm(this: any) {
this.showConfirm = false;
this.pendingAction = null;
},
}));
+43 -23
View File
@@ -205,7 +205,8 @@ function renderSectionHTML(section: SectionData): string {
<div class="carousel-container relative group">
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
flex items-center justify-start opacity-0 pointer-events-none
group-hover:pointer-events-auto group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id="${section.id}"
@@ -230,7 +231,8 @@ function renderSectionHTML(section: SectionData): string {
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
flex items-center justify-end opacity-0 pointer-events-none
group-hover:pointer-events-auto group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id="${section.id}"
@@ -268,33 +270,51 @@ function renderDashboardCollections(sections: SectionData[]): void {
}
}
const BOOK_OPEN_ICON =
'<svg class="reader-icon h-5 w-5" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M12 7v14"></path>' +
'<path d="M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z"></path>' +
'<path d="M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z"></path>' +
"</svg>";
function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
const actionHref = book.has_conflict
? `/media/${book.media_item_id}`
: `/readers/${book.media_item_id}`;
const actionLabel = book.has_conflict
? `Resolve progress conflict for ${book.title}`
: `Read ${book.title}`;
const actionTitle = book.has_conflict
? "Resolve progress conflict"
: "Read";
return `
<a href="/media/${book.media_item_id}">
<div class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-media-item-id="${book.media_item_id}"
tabindex="0"
role="button"
aria-label="View ${book.title}">
<div class="aspect-[2/3] overflow-hidden shadow-lg
bg-gradient-to-br from-gray-700 to-gray-900">
<img src="${coverUrl}"
alt="${book.title}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<div class="book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]">
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)">
${book.title}
</h3>
${book.author ? `<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ""}
<div class="flex-shrink-0 w-36 sm:w-40 snap-start">
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer">
<a href="/media/${book.media_item_id}" class="block h-full">
<div class="book-card-cover aspect-[2/3] overflow-hidden" style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);">
<img src="${coverUrl}"
alt="${book.title}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<div class="book-card-meta">
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title="${book.title}">
${book.title}
</h3>
${book.author ? `<p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title="${book.author}">${book.author}</p>` : ""}
</div>
</a>
<div class="book-card-action">
<a href="${actionHref}"
class="book-card-action-btn"
aria-label="${actionLabel}"
title="${actionTitle}">${BOOK_OPEN_ICON}</a>
</div>
</div>
</div>
</a>
`;
}
+121 -51
View File
@@ -372,6 +372,8 @@ document.addEventListener("alpine:init", () => {
interactionMode: "select" as string,
magnifierEnabled: false,
progressText: "",
progressLabel: "",
progressMain: "",
sliderValue: 0,
settings: null as ReaderSettings | null,
justify: true,
@@ -405,6 +407,34 @@ document.addEventListener("alpine:init", () => {
label: string;
startPage: number;
}[],
init() {
const link = document.getElementById("reader-back");
if (!link) return;
const storageKey = "reader_back";
if (document.referrer) {
try {
const ref = new URL(document.referrer);
if (
ref.origin === window.location.origin &&
!ref.pathname.startsWith("/readers/") &&
ref.pathname !== window.location.pathname
) {
sessionStorage.setItem(storageKey, ref.pathname + ref.search);
}
} catch {}
} else {
sessionStorage.removeItem(storageKey);
}
const backUrl =
sessionStorage.getItem(storageKey) ||
link.getAttribute("href") ||
"/dashboard";
link.addEventListener("click", (e: Event) => {
e.preventDefault();
sessionStorage.removeItem(storageKey);
window.location.href = backUrl;
});
},
async initReader(config: {
mediaItemId: string;
fileUrl: string;
@@ -489,21 +519,20 @@ document.addEventListener("alpine:init", () => {
tocItem,
section,
};
const progressStr = this.formatProgress(
const progressParts = this.formatProgressParts(
fraction,
location,
pageItem,
tocItem,
section,
);
this.progressText = progressStr;
this.setProgress(progressParts);
this.sliderValue = fraction;
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
slider.value = fraction;
slider.title = progressStr;
}
const range = e.detail.range as Range | undefined;
if (range) {
@@ -547,6 +576,34 @@ document.addEventListener("alpine:init", () => {
}
this.initTime = Date.now();
this.fetchReadingSpeed();
this.setupViewportInsets();
},
updateViewportInsets() {
const top = document.getElementById("reader-topbar");
const bot = document.getElementById("reader-bottombar");
const vp = document.getElementById("reader-viewport");
if (!top || !bot || !vp) return;
const margin = 6;
vp.style.setProperty("top", `${top.offsetHeight + margin}px`);
vp.style.setProperty("bottom", `${bot.offsetHeight + margin}px`);
},
setupViewportInsets() {
const top = document.getElementById("reader-topbar");
const bot = document.getElementById("reader-bottombar");
this.updateViewportInsets();
window.addEventListener("resize", () => this.updateViewportInsets());
window.addEventListener("orientationchange", () =>
setTimeout(() => this.updateViewportInsets(), 250),
);
if (
typeof ResizeObserver !== "undefined" &&
top &&
bot
) {
const ro = new ResizeObserver(() => this.updateViewportInsets());
ro.observe(top);
ro.observe(bot);
}
},
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
if (Date.now() - this.initTime < 5000) return;
@@ -774,19 +831,20 @@ document.addEventListener("alpine:init", () => {
/* ignore bookmark errors for now */
}
},
formatProgress(
formatProgressParts(
fraction: number,
location: { current: number; next: number; total: number },
pageItem: { id: number; label: string; href: string },
tocItem: FoliateTocItem | null,
section: { current: number; total: number },
): string {
): { label: string; main: string } {
const percent = new Intl.NumberFormat("en", { style: "percent" }).format(
fraction,
);
const chapterLabel = tocItem?.label ? `${tocItem.label} · ` : "";
switch (this.progressMode) {
case "percentage":
return percent;
return { label: "", main: percent };
case "chapter": {
if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) {
const totalSections = this.book?.sections?.filter(
@@ -809,10 +867,10 @@ document.addEventListener("alpine:init", () => {
1,
Math.min(currentPage - chapterStart + 1, chapterPages),
);
const label = tocItem.label
? `${tocItem.label} · `
: "";
return `${label}${currentInChapter} / ${chapterPages}`;
return {
label: chapterLabel,
main: `${currentInChapter} / ${chapterPages}`,
};
}
}
if (tocItem && this.chapterBoundaries.length > 0) {
@@ -836,10 +894,10 @@ document.addEventListener("alpine:init", () => {
1,
Math.min(currentPage - chapterStart + 1, chapterPages),
);
const label = tocItem.label
? `${tocItem.label} · `
: "";
return `${label}${currentInChapter} / ${chapterPages}`;
return {
label: chapterLabel,
main: `${currentInChapter} / ${chapterPages}`,
};
}
}
if (section && this.sectionFractionsArr.length > 1) {
@@ -859,16 +917,19 @@ document.addEventListener("alpine:init", () => {
Math.round((fraction - startFrac) * pageBase),
);
const clamped = Math.min(currentInSec, totalInSec);
const label = tocItem?.label
? `${tocItem.label} · `
: "";
return `${label}${clamped} / ${totalInSec}`;
return {
label: chapterLabel,
main: `${clamped} / ${totalInSec}`,
};
}
}
if (location.total > 0) {
return `${percent} · ${location.current} / ${location.total}`;
return {
label: "",
main: `${percent} · ${location.current} / ${location.total}`,
};
}
return percent;
return { label: "", main: percent };
}
case "time-left": {
if (this.readingSpeedPpm > 0 && location.total > 0) {
@@ -877,30 +938,47 @@ document.addEventListener("alpine:init", () => {
if (mins >= 60) {
const hrs = Math.floor(mins / 60);
const m = mins % 60;
return `${percent} · ~${hrs}h ${m}m left`;
return { label: "", main: `${percent} · ~${hrs}h ${m}m left` };
}
return `${percent} · ~${mins} min left`;
return { label: "", main: `${percent} · ~${mins} min left` };
}
return `${percent} · ~-- min left`;
return { label: "", main: `${percent} · ~-- min left` };
}
default: {
if (this.isFixedLayout) {
const pageInfo = this.getRenderedPageInfo();
if (pageInfo) {
return `${pageInfo.current} / ${pageInfo.total}`;
return { label: "", main: `${pageInfo.current} / ${pageInfo.total}` };
}
}
if (pageItem) {
return `${percent} · Page ${pageItem.label}`;
return { label: "", main: `${percent} · Page ${pageItem.label}` };
}
const pageInfoReflow = this.getRenderedPageInfo();
if (pageInfoReflow) {
return `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`;
return {
label: "",
main: `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`,
};
}
return `${percent} · ${location.current} / ${location.total}`;
return {
label: "",
main: `${percent} · ${location.current} / ${location.total}`,
};
}
}
},
setProgress(parts: { label: string; main: string }, sliderTitle?: string) {
this.progressLabel = parts.label;
this.progressMain = parts.main;
this.progressText = parts.label + parts.main;
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
slider.title = sliderTitle ?? this.progressText;
}
},
cycleProgressMode() {
const modes = ["pages", "chapter", "percentage", "time-left"];
const idx = modes.indexOf(this.progressMode);
@@ -909,19 +987,15 @@ document.addEventListener("alpine:init", () => {
if (this.lastRelocateDetail) {
const { fraction, location, pageItem, tocItem, section } =
this.lastRelocateDetail;
this.progressText = this.formatProgress(
fraction,
location,
pageItem,
tocItem,
section,
this.setProgress(
this.formatProgressParts(
fraction,
location,
pageItem,
tocItem,
section,
),
);
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
slider.title = this.progressText;
}
}
},
async fetchReadingSpeed() {
@@ -944,19 +1018,15 @@ document.addEventListener("alpine:init", () => {
if (this.lastRelocateDetail) {
const { fraction, location, pageItem, tocItem, section } =
this.lastRelocateDetail;
this.progressText = this.formatProgress(
fraction,
location,
pageItem,
tocItem,
section,
this.setProgress(
this.formatProgressParts(
fraction,
location,
pageItem,
tocItem,
section,
),
);
const slider = document.getElementById(
"progress-slider",
) as HTMLInputElement;
if (slider) {
slider.title = this.progressText;
}
}
},
computeFixedLayoutChapterBoundaries() {

Some files were not shown because too many files have changed in this diff Show More