Author SHA1 Message Date
john-okeefe 86bcdf878b fix(reader): keep footer compact on mobile and clear text under chrome
Release / build-and-push (push) Successful in 2m34s
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:57:49 -04:00
john-okeefe a075a5061f feat(ui): refresh auth, entry, admin, and secondary pages
Apply the new design-system primitives across the remaining pages so the
whole app shares one visual language:

- Auth/entry: redesigned index, login, register (centered card layout,
  SVG icons, semantic tokens)
- Admin: sidebar nav with icons, cohesive admin dashboard/users/library/
  processing-issues/settings (tables use border-line + hover states;
  scan/websocket attributes preserved)
- Secondary: analytics (stat cards), devices, progress, conflicts, queue,
  profile + form/modal all migrated to .card/.btn/.input primitives and
  SVG action icons
- Docs + API explorer + setup wizard + collection/rules/modals +
  unlinked books + book-detail modals: consistent chrome, modal scrims
  use --surface-overlay, close buttons use icon-btn
- reader.templ and error.templ intentionally left unchanged
2026-08-05 16:01:22 -04:00
john-okeefe 28e40e302c feat(ui): rebuild shared chrome and core browse/read pages
- Header: sticky app-wide, active nav highlighting, data-driven theme
  picker with active swatch, unified SVG icons, deduped login fragment
  (was duplicated verbatim for desktop + mobile), removed stray console.log
- LibrarySwitcher: cleaner sticky bar, SVG action icons, theme-aware
- BookCard: the atomic unit now has a real surface (was floating text
  when wood-paneling was off due to an undefined --wood-border), with
  cohesive shadow + hover lift; reused across dashboard/bookshelf/series
- Dashboard: calmer section headers, snap carousels with SVG chevrons
- Bookshelf: recompose the filter wall into a search toolbar + collapsible
  Filters panel (all 9 filters + save/load/clear preserved), redesigned
  empty state and pagination
- Book detail, series, collections, browse: cohesive cards, SVG action
  icons, uppercase muted labels, theme-aware badges/links
2026-08-05 16:01:09 -04:00
john-okeefe 6f271d624f 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:00:50 -04:00
135 changed files with 7304 additions and 11740 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
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" 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"
-38
View File
@@ -1,38 +0,0 @@
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)
+6 -57
View File
@@ -14,8 +14,6 @@ import (
"log" "log"
"time" "time"
_ "time/tzdata"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@@ -50,59 +48,15 @@ func main() {
} }
log.Println("✅ Database schema initialized and verified, starting server...") log.Println("✅ Database schema initialized and verified, starting server...")
// Load tunable settings from the DB into the registry. All values fall back // Create login attempt tracker: 5 failed attempts = 15 minute lockout
// to compiled defaults if a row is missing, so this never blocks startup. loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
registry := database.NewSettingsRegistry(queries)
if err := registry.Load(ctx); err != nil {
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
}
// Wire the registry into the package-level password validator so live
// rule changes apply to the echo struct-tag validator and ValidatePassword.
middleware.SetDefaultPasswordSettings(registry)
// 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 from configured (or default) lockout policy.
loginMaxAttempts, loginLockout := registry.LoginLockout()
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute)
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
authHandler.SetSettings(registry)
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries) systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
systemSettingsHandler.SetSettings(registry)
sidecarHandler := handlers.NewSidecarHandler(queries, cfg) sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
sidecarHandler.SetSettings(registry)
libraryHandler := handlers.NewLibraryHandler(queries) libraryHandler := handlers.NewLibraryHandler(queries)
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries) deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
deviceAuthMiddleware.SetSettings(registry)
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries) processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
// Create WebSocket connection manager // Create WebSocket connection manager
@@ -110,11 +64,10 @@ func main() {
progressService := sync.NewProgressService(queries, connManager) progressService := sync.NewProgressService(queries, connManager)
annotationService := sync.NewAnnotationService(queries, connManager) annotationService := sync.NewAnnotationService(queries, connManager)
annotationService.SetSettings(registry) tombstonePurgerCancel := annotationService.StartTombstonePurger()
maintenanceCancel := annotationService.StartDailyMaintenance() defer tombstonePurgerCancel()
defer maintenanceCancel()
queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize) queueProcessor := sync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService) queueProcessor.SetProgressService(progressService)
queueProcessor.SetAnnotationService(annotationService) queueProcessor.SetAnnotationService(annotationService)
@@ -125,8 +78,7 @@ func main() {
libraryService.SyncAllowedExtensions(context.Background()) libraryService.SyncAllowedExtensions(context.Background())
// Create worker for background tasks // Create worker for background tasks
workerCfg := registry.WorkerPoolConfig() worker := services.NewWorker(3, connManager)
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
services.WorkerInstance = worker services.WorkerInstance = worker
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
@@ -139,9 +91,7 @@ func main() {
queueHandler := handlers.NewQueueHandler(queries, queueProcessor) queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub") conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
conversionService.SetSettings(registry)
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
opdsHandler.SetSettings(registry)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries) dashboardService := services.NewDashboardService(queries)
@@ -194,7 +144,6 @@ func main() {
Echo: e, Echo: e,
Queries: queries, Queries: queries,
Cfg: cfg, Cfg: cfg,
Settings: registry,
DBPool: dbPool, DBPool: dbPool,
AuthHandler: authHandler, AuthHandler: authHandler,
LibraryHandler: libraryHandler, LibraryHandler: libraryHandler,
+11 -49
View File
@@ -45,47 +45,11 @@ CREATE TABLE IF NOT EXISTS system_settings (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
-- Extend system_settings with typed metadata so it can back the admin UI's -- Insert default system settings
-- configurable tunables. All columns are nullable for backward compatibility INSERT INTO system_settings (setting_key, setting_value, description) VALUES
-- with the original three rows and any pre-existing data. ('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20); ('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT; ('default_timezone', 'UTC', 'System default timezone')
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
-- Insert default system settings (original scan/timezone rows + tunables).
-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade.
-- ON CONFLICT DO NOTHING preserves any admin-modified values.
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
-- security / auth (live)
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'),
('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
-- security / auth (restart required)
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'),
-- api (live)
('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'),
('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'),
('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'),
('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'),
('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'),
-- sync / performance (live)
('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'),
('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'),
-- sync / performance (restart required)
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
ON CONFLICT (setting_key) DO NOTHING; ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table -- Create refresh_tokens table
@@ -1185,14 +1149,12 @@ CREATE TABLE IF NOT EXISTS system_config (
updated_by UUID REFERENCES users(id) updated_by UUID REFERENCES users(id)
); );
-- One-time cleanup: clear the old placeholder seed so the startup logic -- Pre-seeded values
-- can re-seed from the BASE_URL env var (or the setup wizard can set it). INSERT INTO system_config (key, value) VALUES
UPDATE system_config SET value = '' ('base_url', 'https://bookhoard.example.com'),
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com'; ('opds_base_url', 'https://bookhoard.example.com/opds'),
UPDATE system_config SET value = '' ('api_base_url', 'https://bookhoard.example.com/api')
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds'; ON CONFLICT (key) DO NOTHING;
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 opds_tokens table (device-specific OPDS access tokens)
CREATE TABLE IF NOT EXISTS opds_tokens ( CREATE TABLE IF NOT EXISTS opds_tokens (
+20 -9
View File
@@ -45,19 +45,30 @@ func (c *Config) DatabaseURL() string {
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName) c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
} }
// SystemConfigGetter returns the value for a system config key, or an error. // GetBaseURL returns the base URL from system configuration database with fallback to config/env var
type SystemConfigGetter func(ctx context.Context, key string) (string, error) func GetBaseURL(ctx context.Context, db interface{}) string {
// Try to get from database first
// GetBaseURL returns the base URL from system configuration database, or empty type SystemConfigQuerier interface {
// string if not set. The getter abstraction avoids importing the database package. GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
val, err := getter(ctx, "base_url")
if err == nil && val != "" {
return val
} }
if querier, ok := db.(SystemConfigQuerier); ok {
config, err := querier.GetSystemConfig(ctx, "base_url")
if err == nil && config.Value != "" {
return config.Value
}
}
// Fallback: return empty string - caller should use their own fallback
return "" return ""
} }
// SystemConfigRow represents a system configuration row
type SystemConfigRow struct {
Key string
Value string
}
func getEnv(key, defaultValue string) string { func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {
return value return value
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
package database package database
+1 -7
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
package database package database
@@ -489,11 +489,6 @@ type SystemSettings struct {
SettingValue string `db:"setting_value" json:"setting_value"` SettingValue string `db:"setting_value" json:"setting_value"`
Description pgtype.Text `db:"description" json:"description"` Description pgtype.Text `db:"description" json:"description"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
MinValue pgtype.Text `db:"min_value" json:"min_value"`
MaxValue pgtype.Text `db:"max_value" json:"max_value"`
RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
Category pgtype.Text `db:"category" json:"category"`
} }
type UnlinkedBooks struct { type UnlinkedBooks struct {
@@ -537,4 +532,3 @@ type Users struct {
Timezone pgtype.Text `db:"timezone" json:"timezone"` Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
} }
+2 -5
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
package database package database
@@ -26,7 +26,7 @@ type Querier interface {
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error) CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
// Cleanup expired OPDS tokens // Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error CleanupExpiredRefreshTokens(ctx context.Context) error
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
@@ -137,7 +137,6 @@ type Querier interface {
// Get all system config // Get all system config
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
// Get collection // Get collection
@@ -279,7 +278,6 @@ type Querier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error) GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
// System Settings queries // System Settings queries
GetSystemSetting(ctx context.Context, settingKey string) (string, error) GetSystemSetting(ctx context.Context, settingKey string) (string, error)
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
GetSystemTimezone(ctx context.Context) (string, error) GetSystemTimezone(ctx context.Context) (string, error)
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
// Get universal progress for a book // Get universal progress for a book
@@ -430,7 +428,6 @@ type Querier interface {
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error) UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)
+4 -114
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.30.0
// source: queries.sql // source: queries.sql
package database package database
@@ -186,11 +186,11 @@ func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error {
} }
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision)) DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')
` `
func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error { func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error {
_, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens, dollar_1) _, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens)
return err return err
} }
@@ -2258,41 +2258,6 @@ func (q *Queries) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSetti
return items, nil return items, nil
} }
const GetAllSystemSettingsFull = `-- name: GetAllSystemSettingsFull :many
SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings ORDER BY category, setting_key
`
func (q *Queries) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) {
rows, err := q.db.Query(ctx, GetAllSystemSettingsFull)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SystemSettings{}
for rows.Next() {
var i SystemSettings
if err := rows.Scan(
&i.ID,
&i.SettingKey,
&i.SettingValue,
&i.Description,
&i.UpdatedAt,
&i.SettingType,
&i.MinValue,
&i.MaxValue,
&i.RequiresRestart,
&i.Category,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many
SELECT SELECT
mh.id, mh.id,
@@ -6622,28 +6587,6 @@ func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (stri
return setting_value, err return setting_value, err
} }
const GetSystemSettingFull = `-- name: GetSystemSettingFull :one
SELECT id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category FROM system_settings WHERE setting_key = $1
`
func (q *Queries) GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error) {
row := q.db.QueryRow(ctx, GetSystemSettingFull, settingKey)
var i SystemSettings
err := row.Scan(
&i.ID,
&i.SettingKey,
&i.SettingValue,
&i.Description,
&i.UpdatedAt,
&i.SettingType,
&i.MinValue,
&i.MaxValue,
&i.RequiresRestart,
&i.Category,
)
return i, err
}
const GetSystemTimezone = `-- name: GetSystemTimezone :one const GetSystemTimezone = `-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone' SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'
` `
@@ -12337,56 +12280,3 @@ func (q *Queries) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSett
) )
return i, err return i, err
} }
const UpsertSystemSetting = `-- name: UpsertSystemSetting :one
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (setting_key) DO UPDATE
SET setting_value = EXCLUDED.setting_value,
description = EXCLUDED.description,
setting_type = EXCLUDED.setting_type,
min_value = EXCLUDED.min_value,
max_value = EXCLUDED.max_value,
requires_restart = EXCLUDED.requires_restart,
category = EXCLUDED.category,
updated_at = NOW()
RETURNING id, setting_key, setting_value, description, updated_at, setting_type, min_value, max_value, requires_restart, category
`
type UpsertSystemSettingParams struct {
SettingKey string `db:"setting_key" json:"setting_key"`
SettingValue string `db:"setting_value" json:"setting_value"`
Description pgtype.Text `db:"description" json:"description"`
SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
MinValue pgtype.Text `db:"min_value" json:"min_value"`
MaxValue pgtype.Text `db:"max_value" json:"max_value"`
RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
Category pgtype.Text `db:"category" json:"category"`
}
func (q *Queries) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) {
row := q.db.QueryRow(ctx, UpsertSystemSetting,
arg.SettingKey,
arg.SettingValue,
arg.Description,
arg.SettingType,
arg.MinValue,
arg.MaxValue,
arg.RequiresRestart,
arg.Category,
)
var i SystemSettings
err := row.Scan(
&i.ID,
&i.SettingKey,
&i.SettingValue,
&i.Description,
&i.UpdatedAt,
&i.SettingType,
&i.MinValue,
&i.MaxValue,
&i.RequiresRestart,
&i.Category,
)
return i, err
}
+1 -21
View File
@@ -373,29 +373,9 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1;
-- name: UpdateSystemSetting :exec -- name: UpdateSystemSetting :exec
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1; UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
-- name: UpsertSystemSetting :one
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (setting_key) DO UPDATE
SET setting_value = EXCLUDED.setting_value,
description = EXCLUDED.description,
setting_type = EXCLUDED.setting_type,
min_value = EXCLUDED.min_value,
max_value = EXCLUDED.max_value,
requires_restart = EXCLUDED.requires_restart,
category = EXCLUDED.category,
updated_at = NOW()
RETURNING *;
-- name: GetSystemSettingFull :one
SELECT * FROM system_settings WHERE setting_key = $1;
-- name: GetAllSystemSettings :many -- name: GetAllSystemSettings :many
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key; SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
-- name: GetAllSystemSettingsFull :many
SELECT * FROM system_settings ORDER BY category, setting_key;
-- name: CreateMediaRating :one -- name: CreateMediaRating :one
INSERT INTO media_ratings (media_item_id, user_id, rating) INSERT INTO media_ratings (media_item_id, user_id, rating)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
@@ -1027,7 +1007,7 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL; UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
-- name: CleanupExpiredRefreshTokens :exec -- name: CleanupExpiredRefreshTokens :exec
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision)); DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
-- ============================================ -- ============================================
-- FORMAT DETECTION & PROGRESS -- FORMAT DETECTION & PROGRESS
-342
View File
@@ -1,342 +0,0 @@
package database
// SettingsRegistry provides a typed, cached view over the system_settings table.
// It is the single source of truth for tunable runtime values that used to be
// hardcoded as Go literals.
//
// Consumers call the domain-specific getters (SessionDuration, OpdsPageSize,
// etc.) which read from an in-memory cache. The cache is populated by Load at
// startup and refreshed by Reload whenever a setting is written. Getters always
// fall back to a compiled-in default if the DB value is missing or unparsable,
// so a corrupt or deleted row can never break the app.
//
// SettingsRegistry lives in the database package (rather than its own package)
// so that every consumer already imports database and does not need to take on
// a new package import.
import (
"context"
"log"
"strconv"
"sync"
"time"
)
// SettingType enumerates the value types stored in system_settings.setting_type.
const (
SettingTypeInt = "int"
SettingTypeBool = "bool"
SettingTypeString = "string"
SettingTypeStringList = "string_list"
)
// SecondsPerDay / SecondsPerHour are conversion helpers used by defaults.
const (
SecondsPerMinute = 60
SecondsPerHour = 3600
SecondsPerDay = 86400
)
// SettingDefault holds the fallback value for a key. These mirror the literals that
// were previously hardcoded in the source so an empty/corrupt DB row preserves
// prior behavior exactly.
type SettingDefault struct {
Key string
Value string
Type string
Min string
Max string
RequiresRestart bool
Category string
Group string
Description string
}
// SettingDefaults is the source of truth for fallback values and metadata. New keys
// must be added here AND seeded in database/schema/schema.sql. Entries are ordered
// by (RequiresRestart, Group) so the admin UI renders coherent sub-sections.
var SettingDefaults = []SettingDefault{
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
{Key: "password_require_upper", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one uppercase letter (A-Z)"},
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
{Key: "device_rate_progress_per_min", Value: "120", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device progress requests per minute"},
{Key: "device_rate_metadata_per_min", Value: "30", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device metadata requests per minute"},
{Key: "annotation_tombstone_ttl_days", Value: "30", Type: SettingTypeInt, Min: "1", Max: "3650", Category: "sync", Group: "Annotation Retention", Description: "How long deleted annotations are kept before purge"},
{Key: "conversion_cache_ttl_hours", Value: "24", Type: SettingTypeInt, Min: "1", Max: "720", Category: "performance", Group: "Conversion Cache", Description: "How long converted (kepub) files are cached"},
{Key: "auth_rate_limit_per_min", Value: "10", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "security", Group: "Auth Rate Limiting", Description: "Global auth API rate limit (requests per minute)"},
{Key: "login_max_attempts", Value: "5", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Failed login attempts before lockout"},
{Key: "login_lockout_minutes", Value: "15", Type: SettingTypeInt, Min: "1", Max: "10080", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Lockout duration after too many failed logins"},
{Key: "sync_queue_interval_seconds", Value: "5", Type: SettingTypeInt, Min: "1", Max: "3600", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "How often the sync queue flushes"},
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
}
// defaultBy indexes SettingDefaults by key for O(1) lookup.
var defaultBy = func() map[string]SettingDefault {
m := make(map[string]SettingDefault, len(SettingDefaults))
for _, d := range SettingDefaults {
m[d.Key] = d
}
return m
}()
// Registry caches system_settings values in memory. The zero value is not
// usable; construct with New.
type SettingsRegistry struct {
q *Queries
mu sync.RWMutex
values map[string]string
loadedAt time.Time
}
// New returns a Registry backed by the given queries. The cache is empty
// until Load is called.
func NewSettingsRegistry(q *Queries) *SettingsRegistry {
return &SettingsRegistry{q: q, values: make(map[string]string)}
}
// Load populates the cache from the database. Missing rows fall back to the
// compiled defaults. Safe to call multiple times.
func (r *SettingsRegistry) Load(ctx context.Context) error {
rows, err := r.q.GetAllSystemSettings(ctx)
if err != nil {
return err
}
fresh := make(map[string]string, len(SettingDefaults))
for _, d := range SettingDefaults {
fresh[d.Key] = d.Value
}
for _, row := range rows {
if _, ok := fresh[row.SettingKey]; ok {
fresh[row.SettingKey] = row.SettingValue
}
}
r.mu.Lock()
r.values = fresh
r.loadedAt = time.Now()
r.mu.Unlock()
return nil
}
// Reload refreshes the cache from the database. Should be called after any
// setting write. On error the cache is left untouched and the error is logged.
func (r *SettingsRegistry) Reload(ctx context.Context) {
if err := r.Load(ctx); err != nil {
log.Printf("settings: reload failed: %v", err)
}
}
// raw returns the cached string value for a key (or the default), clamped to
// [min, max] for int-typed keys.
func (r *SettingsRegistry) raw(key string) string {
r.mu.RLock()
v, ok := r.values[key]
r.mu.RUnlock()
if !ok || v == "" {
v = defaultBy[key].Value
}
return v
}
func (r *SettingsRegistry) getInt(key string) int {
d := defaultBy[key]
v := r.raw(key)
n, err := strconv.Atoi(v)
if err != nil {
n, _ = strconv.Atoi(d.Value)
}
if d.Min != "" {
if mn, err := strconv.Atoi(d.Min); err == nil && n < mn {
n = mn
}
}
if d.Max != "" {
if mx, err := strconv.Atoi(d.Max); err == nil && n > mx {
n = mx
}
}
return n
}
func (r *SettingsRegistry) getBool(key string) bool {
v := r.raw(key)
b, err := strconv.ParseBool(v)
if err != nil {
b, _ = strconv.ParseBool(defaultBy[key].Value)
}
return b
}
// ---- Domain-specific getters (call sites use these) ----
// ScanPollInterval is how often the scanner polls, as a duration.
func (r *SettingsRegistry) ScanPollInterval() time.Duration {
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
}
// AutoScanEnabled reports whether auto-scanning is on.
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
// DefaultTimezone returns the configured default timezone name.
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
// SessionDuration is how long a login session / refresh token stays valid.
func (r *SettingsRegistry) SessionDuration() time.Duration {
return time.Duration(r.getInt("session_duration_seconds")) * time.Second
}
// PasswordMinLength is the minimum password length.
func (r *SettingsRegistry) PasswordMinLength() int { return r.getInt("password_min_length") }
// PasswordRules bundles the active complexity requirements.
type PasswordRules struct {
MinLength int
Upper bool
Lower bool
Number bool
Special bool
}
// PasswordRules returns the active password complexity configuration.
func (r *SettingsRegistry) PasswordRules() PasswordRules {
return PasswordRules{
MinLength: r.PasswordMinLength(),
Upper: r.getBool("password_require_upper"),
Lower: r.getBool("password_require_lower"),
Number: r.getBool("password_require_number"),
Special: r.getBool("password_require_special"),
}
}
// AuthRateLimit is the global auth endpoint rate limit (requests/minute). Read
// once at startup.
func (r *SettingsRegistry) AuthRateLimit() int { return r.getInt("auth_rate_limit_per_min") }
// LoginLockout returns (max attempts, lockout duration). Read once at startup.
func (r *SettingsRegistry) LoginLockout() (int, time.Duration) {
return r.getInt("login_max_attempts"), time.Duration(r.getInt("login_lockout_minutes")) * time.Minute
}
// OpdsDefaultPageSize is the default OPDS items-per-page.
func (r *SettingsRegistry) OpdsDefaultPageSize() int { return r.getInt("opds_default_page_size") }
// OpdsMaxPageSize is the maximum items-per-page a client may request.
func (r *SettingsRegistry) OpdsMaxPageSize() int { return r.getInt("opds_max_page_size") }
// DeviceRateLimits bundles the per-route device rate limits (requests/minute).
type DeviceRateLimits struct {
Sync int
Progress int
Metadata int
}
// DeviceRateLimits returns the active device rate limits.
func (r *SettingsRegistry) DeviceRateLimits() DeviceRateLimits {
return DeviceRateLimits{
Sync: r.getInt("device_rate_sync_per_min"),
Progress: r.getInt("device_rate_progress_per_min"),
Metadata: r.getInt("device_rate_metadata_per_min"),
}
}
// TombstoneTTL is how long deleted annotations are retained before purge.
func (r *SettingsRegistry) TombstoneTTL() time.Duration {
return time.Duration(r.getInt("annotation_tombstone_ttl_days")) * 24 * time.Hour
}
// ConversionCacheTTL is how long converted (kepub) files are served from cache.
func (r *SettingsRegistry) ConversionCacheTTL() time.Duration {
return time.Duration(r.getInt("conversion_cache_ttl_hours")) * time.Hour
}
// SyncQueueConfig bundles the sync queue interval and batch size. Read at
// startup; changes require a restart.
type SyncQueueConfig struct {
Interval time.Duration
BatchSize int
}
// SyncQueueConfig returns the active sync queue configuration.
func (r *SettingsRegistry) SyncQueueConfig() SyncQueueConfig {
return SyncQueueConfig{
Interval: time.Duration(r.getInt("sync_queue_interval_seconds")) * time.Second,
BatchSize: r.getInt("sync_queue_batch_size"),
}
}
// WorkerPoolConfig bundles worker count and queue capacity. Read at startup;
// changes require a restart.
type WorkerPoolConfig struct {
Size int
QueueCap int
}
// WorkerPoolConfig returns the active worker pool configuration.
func (r *SettingsRegistry) WorkerPoolConfig() WorkerPoolConfig {
return WorkerPoolConfig{
Size: r.getInt("worker_pool_size"),
QueueCap: r.getInt("worker_queue_cap"),
}
}
// SettingEntry exposes one setting's metadata + current value, for the admin UI/API.
type SettingEntry struct {
Key string `json:"key"`
Value string `json:"value"`
Type string `json:"type"`
Min string `json:"min,omitempty"`
Max string `json:"max,omitempty"`
RequiresRestart bool `json:"requires_restart"`
Category string `json:"category"`
Group string `json:"group"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
}
// All returns metadata + current values for every known setting, grouped by
// the in-memory cache (which reflects the DB after Load/Reload).
func (r *SettingsRegistry) All() []SettingEntry {
r.mu.RLock()
vals := make(map[string]string, len(r.values))
for k, v := range r.values {
vals[k] = v
}
r.mu.RUnlock()
out := make([]SettingEntry, 0, len(SettingDefaults))
for _, d := range SettingDefaults {
v, ok := vals[d.Key]
if !ok {
v = d.Value
}
out = append(out, SettingEntry{
Key: d.Key,
Value: v,
Type: d.Type,
Min: d.Min,
Max: d.Max,
RequiresRestart: d.RequiresRestart,
Category: d.Category,
Group: d.Group,
Description: d.Description,
IsDefault: v == d.Value,
})
}
return out
}
// LookupDefault returns the compiled-in SettingDefault for a key (ok=false if unknown).
func LookupDefault(key string) (SettingDefault, bool) {
d, ok := defaultBy[key]
return d, ok
}
@@ -1,97 +0,0 @@
package database
import (
"strconv"
"testing"
)
// TestSettingDefaults ensures every seeded setting has a compiled default with
// a valid value for its declared type. This guards against typos that would
// silently fall back at runtime.
func TestSettingDefaults(t *testing.T) {
if len(SettingDefaults) == 0 {
t.Fatal("SettingDefaults is empty")
}
for _, d := range SettingDefaults {
if d.Key == "" {
t.Errorf("default has empty key: %+v", d)
continue
}
switch d.Type {
case SettingTypeInt:
if _, err := strconv.Atoi(d.Value); err != nil {
t.Errorf("int setting %s default %q is not an int: %v", d.Key, d.Value, err)
}
if d.Min != "" {
if _, err := strconv.Atoi(d.Min); err != nil {
t.Errorf("int setting %s min %q is not an int", d.Key, d.Min)
}
}
if d.Max != "" {
if _, err := strconv.Atoi(d.Max); err != nil {
t.Errorf("int setting %s max %q is not an int", d.Key, d.Max)
}
}
case SettingTypeBool:
if _, err := strconv.ParseBool(d.Value); err != nil {
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
}
case SettingTypeString:
if d.Value == "" {
t.Errorf("string setting %s has empty default", d.Key)
}
default:
t.Errorf("setting %s has unknown type %q", d.Key, d.Type)
}
}
}
// TestSettingsRegistryGetIntClamping verifies that out-of-range DB values are
// clamped to the declared min/max, and that garbage falls back to the default.
func TestSettingsRegistryGetIntClamping(t *testing.T) {
r := &SettingsRegistry{values: map[string]string{}, q: nil}
// Seed with an over-max value; expect clamping to the max (3600).
r.values["scan_poll_interval_seconds"] = "999999"
if got := r.ScanPollInterval(); got.Seconds() != 3600 {
t.Errorf("expected clamp to 3600, got %v", got)
}
// Seed with an under-min value; expect clamp to min (1).
r.values["scan_poll_interval_seconds"] = "0"
if got := r.ScanPollInterval(); got.Seconds() != 1 {
t.Errorf("expected clamp to 1, got %v", got)
}
// Seed with garbage; expect fallback to default (60).
r.values["scan_poll_interval_seconds"] = "not-a-number"
if got := r.ScanPollInterval(); got.Seconds() != 60 {
t.Errorf("expected fallback default 60, got %v", got)
}
}
// TestSettingsRegistryGetBoolFallback verifies bool parsing and fallback.
func TestSettingsRegistryGetBoolFallback(t *testing.T) {
r := &SettingsRegistry{values: map[string]string{}, q: nil}
r.values["auto_scan_enabled"] = "true"
if !r.AutoScanEnabled() {
t.Error("expected true")
}
r.values["auto_scan_enabled"] = "garbage"
// garbage falls back to default ("true")
if !r.AutoScanEnabled() {
t.Error("expected fallback to default true")
}
}
// TestLookupDefaultUnknownKey verifies unknown keys return ok=false.
func TestLookupDefaultUnknownKey(t *testing.T) {
if _, ok := LookupDefault("does_not_exist"); ok {
t.Error("expected ok=false for unknown key")
}
if _, ok := LookupDefault("session_duration_seconds"); !ok {
t.Error("expected ok=true for known key")
}
}
+11 -32
View File
@@ -26,34 +26,14 @@ import (
) )
const ( const (
// DefaultSessionDuration is the fallback session duration used when no // Session duration constants
// settings registry is wired (matches the historical 7-day value). // Follows same pattern as refresh_token.go
DefaultSessionDuration = 7 * 24 * time.Hour SessionDuration = 7 * 24 * time.Hour // 7 days
) )
// SessionDurationSec is retained for backward compatibility; new code uses the // SessionDurationSec is the session duration in seconds for use in cookies and API responses
// registry via AuthHandler.sessionDuration(). // Note: This is computed from SessionDuration to avoid magic numbers
var SessionDurationSec = int(DefaultSessionDuration.Seconds()) var SessionDurationSec = int(SessionDuration.Seconds())
// SetSettings wires the tunable settings registry (optional).
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
// sessionDuration returns the active session duration from the registry.
func (h *AuthHandler) sessionDuration() time.Duration {
if h.settings != nil {
return h.settings.SessionDuration()
}
return DefaultSessionDuration
}
// refreshTokenTTL returns the active refresh-token lifetime (shared with the
// session duration), with a compiled-default fallback.
func (h *AuthHandler) refreshTokenTTL() time.Duration {
if h.settings != nil {
return h.settings.SessionDuration()
}
return DefaultSessionDuration
}
var secure = os.Getenv("COOKIE_SECURE") var secure = os.Getenv("COOKIE_SECURE")
@@ -61,7 +41,6 @@ type AuthHandler struct {
db *database.Queries db *database.Queries
jwtKey []byte jwtKey []byte
loginAttemptTracker *middleware.LoginAttemptTracker loginAttemptTracker *middleware.LoginAttemptTracker
settings *database.SettingsRegistry
} }
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler { func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
@@ -286,7 +265,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
HttpOnly: true, HttpOnly: true,
Secure: secure == "true", // TODO: Set to true in production with HTTPS Secure: secure == "true", // TODO: Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: int(h.sessionDuration().Seconds()), MaxAge: SessionDurationSec,
} }
c.SetCookie(cookie) c.SetCookie(cookie)
@@ -319,7 +298,7 @@ window.location.href = '/dashboard';
Token: accessToken, Token: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
TokenType: "Bearer", TokenType: "Bearer",
ExpiresIn: int(h.sessionDuration().Seconds()), ExpiresIn: SessionDurationSec,
User: UserProfile{ User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(), ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email, Email: user.Email,
@@ -432,7 +411,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
HttpOnly: true, HttpOnly: true,
Secure: secure == "true", // TODO: Set to true in production with HTTPS Secure: secure == "true", // TODO: Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: int(h.sessionDuration().Seconds()), MaxAge: SessionDurationSec,
} }
c.SetCookie(cookie) c.SetCookie(cookie)
@@ -471,7 +450,7 @@ window.location.href = '%s';
Token: accessToken, Token: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
TokenType: "Bearer", TokenType: "Bearer",
ExpiresIn: int(h.sessionDuration().Seconds()), ExpiresIn: SessionDurationSec,
User: UserProfile{ User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(), ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email, Email: user.Email,
@@ -1035,7 +1014,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
"user_role": userRole, "user_role": userRole,
"user_email": userEmail, "user_email": userEmail,
"user_username": userUsername, "user_username": userUsername,
"exp": time.Now().Add(h.sessionDuration()).Unix(), "exp": time.Now().Add(SessionDuration).Unix(),
"iat": time.Now().Unix(), "iat": time.Now().Unix(),
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
-1
View File
@@ -73,7 +73,6 @@ type BookInfo struct {
Title string `json:"title"` Title string `json:"title"`
Author string `json:"author"` Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"` CoverImagePath string `json:"cover_image_path"`
HasConflict bool `json:"has_conflict"`
} }
type SectionData struct { type SectionData struct {
-55
View File
@@ -4,7 +4,6 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"context"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@@ -65,7 +64,6 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
} }
sectionData := BuildSections(sections, libraryID) sectionData := BuildSections(sections, libraryID)
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData}) return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
} }
@@ -190,59 +188,6 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
return result 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 { func getViewAllURL(collectionID string, libraryID string) string {
if collectionID != "" { if collectionID != "" {
if libraryID != "" { if libraryID != "" {
+1 -1
View File
@@ -623,7 +623,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
} }
if h.annotationSvc != nil && len(processedBooks) > 0 { if h.annotationSvc != nil && len(processedBooks) > 0 {
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true} cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
for mediaItemID, contentId := range processedBooks { for mediaItemID, contentId := range processedBooks {
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
+1 -1
View File
@@ -854,7 +854,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark) annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
} }
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true} cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{ tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: pgBookUUID, MediaItemID: pgBookUUID,
UserID: pgUserID, UserID: pgUserID,
+9 -30
View File
@@ -26,26 +26,6 @@ type OPDSHandler struct {
conversionService interface { conversionService interface {
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
} }
settings *database.SettingsRegistry
}
// SetSettings wires the tunable settings registry (OPDS page size).
func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
// opdsDefaultPageSize returns the configured default page size (50 if unset).
func (h *OPDSHandler) opdsDefaultPageSize() int {
if h.settings != nil {
return h.settings.OpdsDefaultPageSize()
}
return 50
}
// opdsMaxPageSize returns the configured maximum page size (200 if unset).
func (h *OPDSHandler) opdsMaxPageSize() int {
if h.settings != nil {
return h.settings.OpdsMaxPageSize()
}
return 200
} }
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface { func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
@@ -58,15 +38,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
} }
} }
// Helper function to get base URL from system config with request-derived fallback // Helper function to get base URL from system config
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) { func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
var dbBaseURL string baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil { if err != nil {
dbBaseURL = config.Value return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
} }
baseURL := deriveBaseURL(c, dbBaseURL)
opdsBaseURL := baseURL + "/opds" opdsBaseURL := baseURL.Value + "/opds"
return baseURL, opdsBaseURL, nil return baseURL.Value, opdsBaseURL, nil
} }
func (h *OPDSHandler) getAuthToken(c *echo.Context) string { func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
@@ -188,10 +168,9 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
} }
} }
perPageNum := h.opdsDefaultPageSize() perPageNum := 50
maxPerPage := h.opdsMaxPageSize()
if perPage != "" { if perPage != "" {
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage { if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 {
perPageNum = num perPageNum = num
} }
} }
-6
View File
@@ -2,7 +2,6 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"context"
"net/http" "net/http"
"time" "time"
@@ -140,8 +139,3 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
"message": "Issue deleted", "message": "Issue deleted",
}) })
} }
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
return h.db.GetProcessingIssueStats(ctx, libraryID)
}
-10
View File
@@ -374,11 +374,6 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
deviceName = progress.LastSyncDevice.String deviceName = progress.LastSyncDevice.String
} }
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
}
progressList = append(progressList, ProgressWithMedia{ progressList = append(progressList, ProgressWithMedia{
MediaItemID: progress.MediaItemID.Bytes, MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title, Title: mediaItem.Title,
@@ -391,11 +386,6 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
Epubcfi: epubcfi, Epubcfi: epubcfi,
LastSyncDevice: deviceName, LastSyncDevice: deviceName,
ProgressPercentage: progress.Percentage.Float64 * 100, ProgressPercentage: progress.Percentage.Float64 * 100,
EpubCFI: epubcfi,
LastUpdated: lastUpdated,
DeviceIcon: getDeviceIcon(deviceName),
DeviceName: deviceName,
DeviceType: deviceName,
FormatGroup: mediaItem.FormatGroup, FormatGroup: mediaItem.FormatGroup,
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64), EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
}) })
+6 -2
View File
@@ -14,6 +14,10 @@ import (
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
const (
refreshTokenExpiration = 7 * 24 * time.Hour // 7 days
)
type RefreshTokenRequest struct { type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" validate:"required"` RefreshToken string `json:"refresh_token" validate:"required"`
} }
@@ -68,7 +72,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
return c.JSON(http.StatusOK, RefreshTokenResponse{ return c.JSON(http.StatusOK, RefreshTokenResponse{
AccessToken: accessToken, AccessToken: accessToken,
TokenType: "Bearer", TokenType: "Bearer",
ExpiresIn: int(h.refreshTokenTTL().Seconds()), ExpiresIn: SessionDurationSec,
}) })
} }
@@ -97,7 +101,7 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
tokenUUID := uuid.New() tokenUUID := uuid.New()
refreshToken := tokenUUID.String() refreshToken := tokenUUID.String()
expiresAt := time.Now().Add(h.refreshTokenTTL()) expiresAt := time.Now().Add(refreshTokenExpiration)
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{ _, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true}, UserID: pgtype.UUID{Bytes: userID, Valid: true},
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true}, Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
+18 -27
View File
@@ -3,7 +3,6 @@ package handlers
import ( import (
"bookhoard/internal/config" "bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/setupstatus"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -17,17 +16,12 @@ import (
type SidecarHandler struct { type SidecarHandler struct {
db *database.Queries db *database.Queries
cfg *config.Config cfg *config.Config
settings *database.SettingsRegistry
} }
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler { func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
return &SidecarHandler{db: db, cfg: cfg} return &SidecarHandler{db: db, cfg: cfg}
} }
// SetSettings wires the tunable settings registry so the timezone write path
// keeps the cache consistent.
func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
type SidecarConfig struct { type SidecarConfig struct {
Version string `json:"version"` Version string `json:"version"`
Bookhoard SidecarBookhoardConfig `json:"bookhoard"` Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
@@ -87,13 +81,15 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true} pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get base URL and compute paths (with request-derived fallback) // Get base URL and compute paths
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url") baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
baseURL := deriveBaseURL(c, dbBaseURL.Value) if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Generate URLs // Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String()) opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL) syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
// Get user's visible libraries with media items // Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -180,8 +176,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{ Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL, OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL, SyncAPI: syncAPIURL,
OPDSBaseURL: baseURL + "/opds", OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: baseURL + "/api", APIBaseURL: baseURL.Value + "/api",
DeviceID: deviceID.String(), DeviceID: deviceID.String(),
DeviceToken: device.AuthToken, DeviceToken: device.AuthToken,
}, },
@@ -223,13 +219,15 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true} pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get base URL and compute paths (with request-derived fallback) // Get base URL and compute paths
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url") baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
baseURL := deriveBaseURL(c, dbBaseURL.Value) if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Generate URLs // Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String()) opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL) syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
// Get user's visible libraries with media items // Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -311,8 +309,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{ Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL, OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL, SyncAPI: syncAPIURL,
OPDSBaseURL: baseURL + "/opds", OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: baseURL + "/api", APIBaseURL: baseURL.Value + "/api",
DeviceID: deviceID.String(), DeviceID: deviceID.String(),
DeviceToken: device.AuthToken, DeviceToken: device.AuthToken,
}, },
@@ -405,9 +403,6 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
"error": "failed to update default timezone", "error": "failed to update default timezone",
}) })
} }
if h.settings != nil {
h.settings.Reload(ctx)
}
continue continue
} }
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{ _, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
@@ -439,10 +434,6 @@ 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 // Check for HTMX request
-164
View File
@@ -2,21 +2,17 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
type SystemSettingsHandler struct { type SystemSettingsHandler struct {
db *database.Queries db *database.Queries
settings *database.SettingsRegistry
} }
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler { func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
@@ -25,154 +21,6 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
} }
} }
// SetSettings wires the tunable settings registry. Required for the unified
// /api/system/settings endpoints and for cache invalidation after writes.
func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) {
h.settings = s
}
// reload refreshes the in-memory cache after a write.
func (h *SystemSettingsHandler) reload(c *echo.Context) {
if h.settings != nil {
h.settings.Reload(c.Request().Context())
}
}
// ---- Unified /api/system/settings endpoints ----
// GetSettings handles GET /api/system/settings.
func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error {
if h.settings == nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
}
return c.JSON(http.StatusOK, h.settings.All())
}
// UpdateSettingRequest is the body for PUT /api/system/settings.
type UpdateSettingRequest struct {
Key string `json:"key" form:"key"`
Value string `json:"value" form:"value"`
}
// UpdateSettingResponse mirrors a settings entry plus a reload hint.
type UpdateSettingResponse struct {
database.SettingEntry
ReloadRequired bool `json:"reload_required"`
Message string `json:"message,omitempty"`
}
// UpdateSetting handles PUT /api/system/settings.
func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error {
if h.settings == nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
}
var req UpdateSettingRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, resp)
}
// ApplySetting validates, persists, and reloads a single setting. Shared by the
// JSON API and the HTMX admin endpoint.
func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) {
if h.settings == nil {
return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized")
}
if key == "" {
return UpdateSettingResponse{}, fmt.Errorf("key is required")
}
def, ok := database.LookupDefault(key)
if !ok {
return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key)
}
if err := validateSettingValue(def, value); err != nil {
return UpdateSettingResponse{}, err
}
desc := def.Description
rType := pgtype.Text{}
if def.Type != "" {
rType = pgtype.Text{String: def.Type, Valid: true}
}
var minP, maxP pgtype.Text
if def.Min != "" {
minP = pgtype.Text{String: def.Min, Valid: true}
}
if def.Max != "" {
maxP = pgtype.Text{String: def.Max, Valid: true}
}
if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{
SettingKey: key,
SettingValue: value,
Description: pgtype.Text{String: desc, Valid: desc != ""},
SettingType: rType,
MinValue: minP,
MaxValue: maxP,
RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true},
Category: pgtype.Text{String: def.Category, Valid: def.Category != ""},
}); err != nil {
return UpdateSettingResponse{}, err
}
h.settings.Reload(ctx)
resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart}
for _, e := range h.settings.All() {
if e.Key == key {
resp.SettingEntry = e
break
}
}
if def.RequiresRestart {
resp.Message = "Saved. Restart the server for this change to take full effect."
} else {
resp.Message = "Saved."
}
return resp, nil
}
// validateSettingValue checks a candidate value against the setting's type and bounds.
func validateSettingValue(def database.SettingDefault, value string) error {
switch def.Type {
case database.SettingTypeInt:
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("value must be an integer")
}
if def.Min != "" {
if mn, err := strconv.Atoi(def.Min); err == nil && n < mn {
return fmt.Errorf("value must be >= %s", def.Min)
}
}
if def.Max != "" {
if mx, err := strconv.Atoi(def.Max); err == nil && n > mx {
return fmt.Errorf("value must be <= %s", def.Max)
}
}
case database.SettingTypeBool:
if _, err := strconv.ParseBool(value); err != nil {
return fmt.Errorf("value must be true or false")
}
case database.SettingTypeString:
if value == "" {
return fmt.Errorf("value must not be empty")
}
if def.Key == "default_timezone" {
if _, err := time.LoadLocation(value); err != nil {
return fmt.Errorf("invalid timezone: %v", err)
}
}
}
return nil
}
// ---- Legacy scan-settings endpoints (retained for backward compatibility) ----
type UpdateScanSettingsRequest struct { type UpdateScanSettingsRequest struct {
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"` ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
AutoScanEnabled bool `json:"auto_scan_enabled"` AutoScanEnabled bool `json:"auto_scan_enabled"`
@@ -203,7 +51,6 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
if err != nil { if err != nil {
return err return err
} }
h.reload(c)
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"}) return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
} }
@@ -241,8 +88,6 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
h.reload(c)
return c.JSON(http.StatusOK, ScanSettingsResponse{ return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds, ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
AutoScanEnabled: req.AutoScanEnabled, AutoScanEnabled: req.AutoScanEnabled,
@@ -251,15 +96,6 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
} }
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error { func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
// Prefer the registry (single source of truth after Load).
if h.settings != nil {
interval := int32(h.settings.ScanPollInterval().Seconds())
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: interval,
AutoScanEnabled: h.settings.AutoScanEnabled(),
})
}
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds") scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
-36
View File
@@ -1,36 +0,0 @@
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
}
+7 -40
View File
@@ -25,7 +25,6 @@ type DeviceContext struct {
type DeviceAuthMiddleware struct { type DeviceAuthMiddleware struct {
db *database.Queries db *database.Queries
rateLimiter *DeviceRateLimiter rateLimiter *DeviceRateLimiter
settings *database.SettingsRegistry
} }
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware { func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
@@ -35,41 +34,6 @@ func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
} }
} }
// SetSettings wires the tunable settings registry so device rate limits are
// read live on each authenticated request.
func (m *DeviceAuthMiddleware) SetSettings(s *database.SettingsRegistry) { m.settings = s }
// rateLimitConfig returns the active device rate limits from the registry, or
// the historical defaults when no registry is wired.
func (m *DeviceAuthMiddleware) rateLimitConfig() DeviceRateLimitConfig {
if m.settings != nil {
dl := m.settings.DeviceRateLimits()
return DeviceRateLimitConfig{
SyncRequestsPerMinute: dl.Sync,
ProgressUpdatesPerMinute: dl.Progress,
MetadataRequestsPerMinute: dl.Metadata,
}
}
return DeviceRateLimitConfig{
SyncRequestsPerMinute: DefaultSyncRequestsPerMinute,
ProgressUpdatesPerMinute: DefaultProgressUpdatesPerMinute,
MetadataRequestsPerMinute: DefaultMetadataRequestsPerMinute,
}
}
// rateLimitForRequestType returns the configured per-minute limit for a given
// request type, for use in X-RateLimit-* headers.
func (m *DeviceAuthMiddleware) rateLimitForRequestType(requestType string, config DeviceRateLimitConfig) int {
switch requestType {
case "progress":
return config.ProgressUpdatesPerMinute
case "metadata":
return config.MetadataRequestsPerMinute
default: // "sync" and any unknown type
return config.SyncRequestsPerMinute
}
}
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc { func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error { return func(c *echo.Context) error {
var device database.Devices var device database.Devices
@@ -151,12 +115,15 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
deviceUUID := uuid.UUID(device.ID.Bytes) deviceUUID := uuid.UUID(device.ID.Bytes)
deviceID := deviceUUID.String() deviceID := deviceUUID.String()
config := m.rateLimitConfig() config := DeviceRateLimitConfig{
limitForType := m.rateLimitForRequestType(requestType, config) SyncRequestsPerMinute: 60,
ProgressUpdatesPerMinute: 120,
MetadataRequestsPerMinute: 30,
}
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) { if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType)) c.Response().Header().Set("X-RateLimit-Limit", "60")
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
c.Response().Header().Set("X-RateLimit-Reset", "60") c.Response().Header().Set("X-RateLimit-Reset", "60")
return c.JSON(http.StatusTooManyRequests, map[string]string{ return c.JSON(http.StatusTooManyRequests, map[string]string{
@@ -167,7 +134,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
} }
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType)) c.Response().Header().Set("X-RateLimit-Limit", "60")
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining)) c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
ctx := DeviceContext{ ctx := DeviceContext{
+52 -102
View File
@@ -1,146 +1,96 @@
package middleware package middleware
import ( import (
"bookhoard/internal/database"
"fmt" "fmt"
"regexp" "regexp"
"sync"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
// specialCharRegex matches the historical "special character" set used by the // PasswordValidator validates password complexity requirements
// password complexity rules. type PasswordValidator struct{}
const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`
// PasswordValidator validates password complexity against the configured rules. // Validate checks if a password meets complexity requirements:
// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the // - Minimum 8 characters
// regex set is recompiled under a mutex on each validation. Without a registry // - At least one uppercase letter
// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply. // - At least one lowercase letter
type PasswordValidator struct { // - At least one number
settings *database.SettingsRegistry // - At least one special character
}
// SetSettings wires the tunable settings registry.
func (v *PasswordValidator) SetSettings(s *database.SettingsRegistry) { v.settings = s }
// compileSpecialRegex isolates the regexp compile (which is safe to call
// concurrently, but we keep it behind a cached var for the no-registry path).
var (
specialOnce sync.Once
specialRe *regexp.Regexp
)
func specialRegex() *regexp.Regexp {
specialOnce.Do(func() {
specialRe = regexp.MustCompile(specialCharRegex)
})
return specialRe
}
func (v *PasswordValidator) rules() database.PasswordRules {
if v.settings != nil {
return v.settings.PasswordRules()
}
return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true}
}
// Validate checks if a password meets the configured complexity requirements.
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool { func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
return v.CheckPassword(fl.Field().String()) password := fl.Field().String()
// Check minimum length
if len(password) < 8 {
return false
} }
// CheckPassword applies the active rules to a single password. // Check for uppercase
func (v *PasswordValidator) CheckPassword(password string) bool { hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
r := v.rules() if !hasUpper {
if len(password) < r.MinLength {
return false return false
} }
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
// Check for lowercase
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
if !hasLower {
return false return false
} }
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
// Check for number
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
if !hasNumber {
return false return false
} }
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
return false // Check for special character
} hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
if r.Special && !specialRegex().MatchString(password) { if !hasSpecial {
return false return false
} }
return true return true
} }
// GetPasswordRequirements returns a human-readable list of the active password // GetPasswordRequirements returns a human-readable list of password requirements
// requirements, driven by the configured rules when a registry is wired.
func GetPasswordRequirements() []string { func GetPasswordRequirements() []string {
return defaultPasswordValidator.Requirements() return []string{
"At least 8 characters long",
"At least one uppercase letter (A-Z)",
"At least one lowercase letter (a-z)",
"At least one number (0-9)",
"At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
}
} }
// Requirements returns the human-readable list for the receiver's active rules. // ValidatePassword checks a password and returns an error if it doesn't meet requirements
func (v *PasswordValidator) Requirements() []string {
r := v.rules()
var out []string
out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength))
if r.Upper {
out = append(out, "At least one uppercase letter (A-Z)")
}
if r.Lower {
out = append(out, "At least one lowercase letter (a-z)")
}
if r.Number {
out = append(out, "At least one number (0-9)")
}
if r.Special {
out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)")
}
return out
}
// ValidatePassword checks a password against the default (hardcoded) rules and
// returns an error describing the first unmet requirement. Retained for callers
// that don't have access to a configured PasswordValidator instance.
func ValidatePassword(password string) error { func ValidatePassword(password string) error {
v := defaultPasswordValidator if len(password) < 8 {
r := v.rules() return fmt.Errorf("password must be at least 8 characters long")
if len(password) < r.MinLength {
return fmt.Errorf("password must be at least %d characters long", r.MinLength)
} }
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one uppercase letter") return fmt.Errorf("password must contain at least one uppercase letter")
} }
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one lowercase letter") return fmt.Errorf("password must contain at least one lowercase letter")
} }
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
return fmt.Errorf("password must contain at least one number") return fmt.Errorf("password must contain at least one number")
} }
if r.Special && !specialRegex().MatchString(password) {
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
return fmt.Errorf("password must contain at least one special character") return fmt.Errorf("password must contain at least one special character")
} }
return nil return nil
} }
// defaultPasswordValidator is used by the package-level helpers // RegisterPasswordValidation registers the password validator with the validator instance
// (GetPasswordRequirements, ValidatePassword) and as the fallback inside
// RegisterPasswordValidation when no registry has been wired. Callers that want
// live rule updates should construct their own PasswordValidator and call
// SetSettings.
var defaultPasswordValidator = &PasswordValidator{}
// RegisterPasswordValidation registers the password validator with the
// validator instance. The registered func re-evaluates rules on every call, so
// changes to the wired registry take effect immediately.
func RegisterPasswordValidation(v *validator.Validate) error { func RegisterPasswordValidation(v *validator.Validate) error {
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool { return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
return defaultPasswordValidator.CheckPassword(fl.Field().String()) pv := &PasswordValidator{}
return pv.Validate(fl)
}) })
} }
// SetDefaultPasswordSettings wires the settings registry into the package-level
// default validator so that the struct-tag validator (used by echo's
// CustomValidator) and ValidatePassword follow live configuration. Intended to
// be called once at startup.
func SetDefaultPasswordSettings(s *database.SettingsRegistry) {
defaultPasswordValidator.SetSettings(s)
}
-447
View File
@@ -1,447 +0,0 @@
package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/templates"
"bytes"
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
)
func registerAdminLibraryRoutes(cfg *Config, frontendProtected *echo.Group) {
g := frontendProtected.Group("", handlers.AdminMiddleware)
// HTMX: Create library
g.POST("/admin/library/create", func(c *echo.Context) error {
user := c.Get("user").(database.Users)
name := c.FormValue("name")
desc := c.FormValue("description")
libType := c.FormValue("type")
if name == "" || libType == "" {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name and type are required</div>`)
}
_, err := cfg.LibraryService.CreateLibrary(
c.Request().Context(),
name,
desc,
libType,
user.ID,
)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to create library</div>`)
}
return renderLibraryList(c, cfg)
})
// HTMX: Update library
g.PUT("/admin/library/:id", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
name := c.FormValue("name")
desc := c.FormValue("description")
if name == "" {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name is required</div>`)
}
_, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update library</div>`)
}
return renderLibraryList(c, cfg)
})
// HTMX: Delete library
g.DELETE("/admin/library/:id", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to delete library</div>`)
}
return renderLibraryList(c, cfg)
})
// HTMX: Library expanded panel
g.GET("/admin/library/:id/panel", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
return renderLibraryPanel(c, cfg, libraryID)
})
// HTMX: Add folder
g.POST("/admin/library/:id/folders", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
folderPath := c.FormValue("folder_path")
if folderPath == "" {
return renderLibraryPanel(c, cfg, libraryID)
}
if strings.Contains(folderPath, "..") {
return renderLibraryPanelWithError(c, cfg, libraryID, "Path traversal not allowed")
}
cleanPath := filepath.Clean(folderPath)
fileInfo, err := os.Stat(cleanPath)
if err != nil {
return renderLibraryPanelWithError(c, cfg, libraryID, "Folder path does not exist")
}
if !fileInfo.IsDir() {
return renderLibraryPanelWithError(c, cfg, libraryID, "Path must be a directory")
}
_, err = cfg.LibraryService.AddLibraryFolder(c.Request().Context(), libraryID, cleanPath)
if err != nil {
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to add folder: "+err.Error())
}
return renderLibraryPanel(c, cfg, libraryID)
})
// HTMX: Remove folder
g.DELETE("/admin/library/:id/folders", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
folderPath := c.FormValue("folder_path")
if folderPath == "" {
return renderLibraryPanel(c, cfg, libraryID)
}
err = cfg.LibraryService.DeleteLibraryFolder(c.Request().Context(), libraryID, folderPath)
if err != nil {
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to remove folder")
}
return renderLibraryPanel(c, cfg, libraryID)
})
// HTMX: Folder browser
g.GET("/admin/library/browse", func(c *echo.Context) error {
path := c.QueryParam("path")
if path == "" {
path = "/"
}
targetInput := c.QueryParam("target_input")
libraryID := c.QueryParam("library_id")
dirs, currentPath, parentPath, err := cfg.LibraryService.BrowseDirectories(c.Request().Context(), path)
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Cannot browse: `+err.Error()+`</div>`)
}
entries := make([]templates.DirEntry, len(dirs))
for i, d := range dirs {
fullPath := filepath.Join(currentPath, d)
entries[i] = templates.DirEntry{Name: d, Path: fullPath}
}
var buf bytes.Buffer
err = templates.FolderBrowserContent(currentPath, parentPath, entries, targetInput, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// HTMX: Set user visibility for library
g.POST("/admin/library/:id/visibility", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
userIDStr := c.FormValue("user_id")
isVisible := c.FormValue("is_visible") == "true"
userID, err := parseAdminUUID(userIDStr)
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid user ID</div>`)
}
_, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update visibility</div>`)
}
return renderLibraryPanel(c, cfg, libraryID)
})
}
// renderLibraryList fetches all libraries + users and renders the LibraryList partial.
func renderLibraryList(c *echo.Context, cfg *Config) error {
libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to load libraries</div>`)
}
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
folderCount := getFolderCount(c.Request().Context(), cfg, lib.ID)
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
TypeValue: lib.TypeName,
FolderCount: folderCount,
}
}
users, err := cfg.Queries.ListUsers(c.Request().Context())
if err != nil {
log.Printf("ListUsers failed: %v", err)
users = []database.ListUsersRow{}
}
userData := make([]templates.User, len(users))
for i, u := range users {
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
userData[i] = templates.User{
ID: userUUID.String(),
Username: u.Username,
Email: u.Email,
Role: u.Role,
}
}
var buf bytes.Buffer
err = templates.LibraryList(templates.User{}, libData, userData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}
// renderLibraryPanel fetches library details and renders the LibraryPanel partial.
func renderLibraryPanel(c *echo.Context, cfg *Config, libraryID pgtype.UUID) error {
return renderLibraryPanelWithError(c, cfg, libraryID, "")
}
func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.UUID, errMsg string) error {
ctx := c.Request().Context()
libraryIDStr := uuid.UUID(libraryID.Bytes).String()
// Get library details
lib, err := cfg.LibraryService.GetLibrary(ctx, libraryID)
if err != nil {
return c.HTML(http.StatusNotFound, `<div class="text-sm" style="color: var(--status-danger);">Library not found</div>`)
}
libData := templates.LibraryData{
ID: libraryIDStr,
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
TypeValue: lib.TypeName,
}
// Get folders
dbFolders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
if err != nil {
log.Printf("GetLibraryFolders failed: %v", err)
}
folders := make([]templates.FolderData, len(dbFolders))
for i, f := range dbFolders {
folders[i] = templates.FolderData{FolderPath: f.FolderPath}
}
libData.FolderCount = len(folders)
// Get users
dbUsers, err := cfg.Queries.ListUsers(ctx)
if err != nil {
log.Printf("ListUsers failed: %v", err)
dbUsers = []database.ListUsersRow{}
}
userData := make([]templates.User, len(dbUsers))
for i, u := range dbUsers {
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
userData[i] = templates.User{
ID: userUUID.String(),
Username: u.Username,
Email: u.Email,
}
}
// Get visibility for all users
visibility := make([]templates.UserVisibilityData, len(userData))
for i, u := range userData {
userUUID, _ := parseAdminUUID(u.ID)
visibleLibs, err := cfg.LibraryService.GetUserVisibleLibraries(ctx, userUUID)
if err != nil {
log.Printf("GetUserVisibleLibraries failed: %v", err)
}
isVisible := false
for _, vl := range visibleLibs {
if vl.ID.Bytes == libraryID.Bytes {
isVisible = true
break
}
}
visibility[i] = templates.UserVisibilityData{
UserID: u.ID,
Username: u.Username,
Email: u.Email,
IsVisible: isVisible,
}
}
// Get issue count
issueStats, err := cfg.ProcessingIssuesHandler.GetProcessingIssueStatsData(ctx, libraryID)
if err != nil {
log.Printf("GetProcessingIssueStats failed: %v", err)
}
issueCount := issueStats.ErrorCount + issueStats.WarningCount + issueStats.InfoCount
// Get current user for template
tmplUser := templates.User{}
if u, ok := c.Get("user").(database.Users); ok {
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
tmplUser = templates.User{
ID: userUUID.String(),
Username: u.Username,
Role: u.Role,
}
}
var buf bytes.Buffer
err = templates.LibraryPanel(tmplUser, libraryIDStr, libData, folders, userData, visibility, int(issueCount)).Render(ctx, &buf)
if err != nil {
return err
}
html := buf.String()
if errMsg != "" {
html = `<div class="p-3 mb-3 rounded-lg text-sm" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); color: var(--status-danger);">` + errMsg + `</div>` + html
}
return c.HTML(http.StatusOK, html)
}
func getFolderCount(ctx context.Context, cfg *Config, libraryID pgtype.UUID) int {
folders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
if err != nil {
return 0
}
return len(folders)
}
func parseAdminUUID(s string) (pgtype.UUID, error) {
parsed, err := uuid.Parse(s)
if err != nil {
return pgtype.UUID{}, err
}
return pgtype.UUID{Bytes: parsed, Valid: true}, nil
}
func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats {
stats := templates.AdminStats{}
libs, _ := cfg.LibraryHandler.ListLibrariesData(ctx)
stats.LibraryCount = len(libs)
users, _ := cfg.Queries.ListUsers(ctx)
stats.UserCount = len(users)
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM media_items").Scan(&stats.MediaCount)
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM devices").Scan(&stats.DeviceCount)
}
return stats
}
func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) {
g := frontendProtected.Group("", handlers.AdminMiddleware)
g.PUT("/admin/settings/scan", func(c *echo.Context) error {
ctx := c.Request().Context()
autoScan := c.FormValue("auto_scan_enabled") == "true"
intervalStr := c.FormValue("scan_poll_interval_seconds")
interval, err := strconv.Atoi(intervalStr)
if err != nil || interval < 1 || interval > 3600 {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Interval must be between 1 and 3600 seconds</div>`)
}
autoScanStr := "false"
if autoScan {
autoScanStr = "true"
}
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
SettingKey: "auto_scan_enabled",
SettingValue: autoScanStr,
})
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
SettingKey: "scan_poll_interval_seconds",
SettingValue: strconv.Itoa(interval),
})
// Refresh the registry cache so the change is visible immediately.
if cfg.Settings != nil {
cfg.Settings.Reload(ctx)
}
scanSettings := templates.ScanSettingsData{
AutoScanEnabled: autoScan,
ScanPollIntervalSeconds: interval,
}
var buf bytes.Buffer
_ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf)
return c.HTML(http.StatusOK, buf.String())
})
// HTMX endpoint for saving a single tunable setting. Returns a small HTML
// status snippet rendered into the row's status span.
g.PUT("/admin/settings/tunable", func(c *echo.Context) error {
ctx := c.Request().Context()
key := c.FormValue("key")
value := c.FormValue("value")
if cfg.SystemSettingsHandler == nil {
return c.HTML(http.StatusServiceUnavailable, `<span style="color: var(--status-danger);">settings unavailable</span>`)
}
resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value)
if err != nil {
return c.HTML(http.StatusBadRequest, fmt.Sprintf(`<span style="color: var(--status-danger);">%s</span>`, err.Error()))
}
color := "var(--status-success)"
msg := "Saved"
if resp.ReloadRequired {
color = "var(--status-warning)"
msg = "Saved — restart required"
}
return c.HTML(http.StatusOK, fmt.Sprintf(`<span style="color: %s;">%s</span>`, color, msg))
})
}
+18 -73
View File
@@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"time" "time"
"bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services" "bookhoard/internal/services"
@@ -214,9 +215,6 @@ func registerFrontendRoutes(cfg *Config) {
bookInfoList = []handlers.BookInfo{} 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 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) 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 { if err != nil {
@@ -269,9 +267,6 @@ func registerFrontendRoutes(cfg *Config) {
bookInfoList = []handlers.BookInfo{} 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 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) 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 { if err != nil {
@@ -359,7 +354,6 @@ func registerFrontendRoutes(cfg *Config) {
} }
var buf bytes.Buffer 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) err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
@@ -414,8 +408,7 @@ func registerFrontendRoutes(cfg *Config) {
// Get only visible sections for the dashboard display // Get only visible sections for the dashboard display
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections) visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true} sectionData := handlers.BuildSections(visibleSections, libraryID)
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
allSectionsData := handlers.BuildSections(allSections, libraryID) allSectionsData := handlers.BuildSections(allSections, libraryID)
var buf bytes.Buffer var buf bytes.Buffer
@@ -630,7 +623,6 @@ func registerFrontendRoutes(cfg *Config) {
Description: collection.Description.String, Description: collection.Description.String,
Color: collection.Color.String, Color: collection.Color.String,
Icon: collection.Icon.String, Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
} }
var buf bytes.Buffer var buf bytes.Buffer
@@ -733,7 +725,10 @@ func registerFrontendRoutes(cfg *Config) {
} }
// Get base URL from database config with fallback to config/env var // Get base URL from database config with fallback to config/env var
baseURL := cfg.getBaseURL(c.Request().Context()) baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
var buf bytes.Buffer var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf) err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
@@ -810,9 +805,8 @@ func registerFrontendRoutes(cfg *Config) {
if err != nil { if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error") return renderErrorPage(c, "Error loading user", "user_load_error")
} }
stats := getAdminStats(c.Request().Context(), cfg)
var buf bytes.Buffer var buf bytes.Buffer
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf) err = templates.Admin(user).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
} }
@@ -824,9 +818,8 @@ func registerFrontendRoutes(cfg *Config) {
if err != nil { if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error") return renderErrorPage(c, "Error loading user", "user_load_error")
} }
stats := getAdminStats(c.Request().Context(), cfg)
var buf bytes.Buffer var buf bytes.Buffer
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf) err = templates.Admin(user).Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
} }
@@ -850,14 +843,11 @@ func registerFrontendRoutes(cfg *Config) {
libData := make([]templates.LibraryData, len(libraries)) libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries { for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
libData[i] = templates.LibraryData{ libData[i] = templates.LibraryData{
ID: libUUID.String(), ID: libUUID.String(),
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: getText(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
TypeValue: lib.TypeName,
FolderCount: len(folders),
} }
} }
@@ -995,67 +985,24 @@ func registerFrontendRoutes(cfg *Config) {
return renderErrorPage(c, "Error loading user", "user_load_error") return renderErrorPage(c, "Error loading user", "user_load_error")
} }
ctx := c.Request().Context()
// Fetch current system configuration - just base_url // Fetch current system configuration - just base_url
baseURL := cfg.getBaseURL(ctx) baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
systemConfig := map[string]string{ systemConfig := map[string]string{
"base_url": baseURL, "base_url": baseURL,
"default_timezone": "UTC", "default_timezone": "UTC",
} }
defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx) defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
if err == nil && defaultTimezone != "" { if err == nil && defaultTimezone != "" {
systemConfig["default_timezone"] = defaultTimezone systemConfig["default_timezone"] = defaultTimezone
} }
// Fetch scan settings
scanSettings := templates.ScanSettingsData{
AutoScanEnabled: true,
ScanPollIntervalSeconds: 60,
}
if val, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled"); err == nil {
scanSettings.AutoScanEnabled = val == "true"
}
if val, err := cfg.Queries.GetSystemSetting(ctx, "scan_poll_interval_seconds"); err == nil {
if n, err := strconv.Atoi(val); err == nil {
scanSettings.ScanPollIntervalSeconds = n
}
}
// Load tunable settings entries from the registry. Exclude keys that
// already have their own dedicated UI cards (timezone dropdown, scan
// settings) so they aren't listed twice.
dedicatedUI := map[string]bool{
"default_timezone": true,
"scan_poll_interval_seconds": true,
"auto_scan_enabled": true,
}
var tunableSettings []templates.SettingEntry
if cfg.Settings != nil {
for _, e := range cfg.Settings.All() {
if dedicatedUI[e.Key] {
continue
}
tunableSettings = append(tunableSettings, templates.SettingEntry{
Key: e.Key,
Value: e.Value,
Type: e.Type,
Min: e.Min,
Max: e.Max,
RequiresRestart: e.RequiresRestart,
Category: e.Category,
Group: e.Group,
Description: e.Description,
IsDefault: e.IsDefault,
})
}
}
liveGroups, restartGroups := templates.GroupTunableSettings(tunableSettings)
var buf bytes.Buffer var buf bytes.Buffer
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf) err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf)
if err != nil { if err != nil {
return err return err
} }
@@ -1097,11 +1044,6 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
})) }))
// Admin library HTMX endpoints
registerAdminLibraryRoutes(cfg, frontendProtected)
// Admin settings HTMX endpoints
registerAdminSettingsRoutes(cfg, frontendProtected)
// ============================================================================ // ============================================================================
// LEGACY API ROUTES (for backward compatibility) // LEGACY API ROUTES (for backward compatibility)
// ============================================================================ // ============================================================================
@@ -1135,7 +1077,10 @@ func registerFrontendRoutes(cfg *Config) {
} }
// Get base URL from database config with fallback to config/env var // Get base URL from database config with fallback to config/env var
baseURL := cfg.getBaseURL(c.Request().Context()) baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
var buf bytes.Buffer var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf) err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
-2
View File
@@ -38,8 +38,6 @@ func registerLibraryRoutes(cfg *Config) {
adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats) adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats)
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues) adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats) adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
adminLibrary.POST("/:id/scan", func(c *echo.Context) error { adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
libraryID := c.Param("id") libraryID := c.Param("id")
scanReq := map[string]interface{}{ scanReq := map[string]interface{}{
+2 -23
View File
@@ -39,7 +39,6 @@ type Config struct {
Echo *echo.Echo Echo *echo.Echo
Queries *database.Queries Queries *database.Queries
Cfg *config.Config Cfg *config.Config
Settings *database.SettingsRegistry
DBPool interface{} // pgxpool.Pool interface DBPool interface{} // pgxpool.Pool interface
AuthHandler *handlers.AuthHandler AuthHandler *handlers.AuthHandler
LibraryHandler *handlers.LibraryHandler LibraryHandler *handlers.LibraryHandler
@@ -73,24 +72,6 @@ type Config struct {
LibraryService *services.LibraryService 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 // createJWTMiddleware creates a JWT middleware with proper user context setup
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc { func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{ return echojwt.WithConfig(echojwt.Config{
@@ -212,12 +193,10 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
// Setup redirect middleware - must run before all routes // Setup redirect middleware - must run before all routes
e.Pre(setupRedirectMiddleware(cfg)) e.Pre(setupRedirectMiddleware(cfg))
// Rate limiter. The per-minute value comes from the settings registry (DB); // Rate limiter
// the enabled flag stays env-driven since disabling rate limiting is a
// deployment-time decision, not a runtime tunable.
rateLimiterConfig := ratelimit.RateLimiterConfig{ rateLimiterConfig := ratelimit.RateLimiterConfig{
Enabled: cfg.Cfg.RateLimitEnabled, Enabled: cfg.Cfg.RateLimitEnabled,
RequestsPerMinute: cfg.Settings.AuthRateLimit(), RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
CleanupInterval: 5 * time.Minute, CleanupInterval: 5 * time.Minute,
} }
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig) rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
+1 -7
View File
@@ -112,15 +112,9 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath), CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
} }
} }
// Stamp active conflict flags so cards route the play action correctly // Render using BooksGrid template
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
var buf bytes.Buffer var buf bytes.Buffer
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) err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
}
if err != nil { if err != nil {
log.Printf("Template render error: %v", err) log.Printf("Template render error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`) return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
+10 -40
View File
@@ -14,40 +14,7 @@ import (
) )
func isSetupComplete(cfg *Config) bool { func isSetupComplete(cfg *Config) bool {
getter := func(ctx context.Context) (string, error) { return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
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 { func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
@@ -55,16 +22,19 @@ func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(c *echo.Context) error { return func(c *echo.Context) error {
path := c.Request().URL.Path path := c.Request().URL.Path
if isAllowedDuringSetup(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" {
return next(c) return next(c)
} }
if !isSetupComplete(cfg) { 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") return c.Redirect(http.StatusFound, "/setup")
} }
-6
View File
@@ -17,10 +17,4 @@ func registerSystemRoutes(cfg *Config) {
// System configuration routes (admin-only) // System configuration routes (admin-only)
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration) system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration) system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration)
// Unified tunable settings (admin-only). These back the admin UI's
// editable System Settings sections and supersede the legacy
// /api/libraries/scan-settings JSON routes.
system.GET("/settings", cfg.SystemSettingsHandler.GetSettings)
system.PUT("/settings", cfg.SystemSettingsHandler.UpdateSetting)
} }
+2 -22
View File
@@ -16,10 +16,6 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
// defaultConversionCacheTTL is the fallback kepub cache lifetime when no
// settings registry is wired. Matches the historical hardcoded 24h.
const defaultConversionCacheTTL = 24 * time.Hour
type ConvertedKEPUB struct { type ConvertedKEPUB struct {
Path string Path string
SHA256 string SHA256 string
@@ -31,7 +27,6 @@ type ConversionService struct {
cacheDir string cacheDir string
conversionTool string conversionTool string
conversionCacheTTL time.Duration conversionCacheTTL time.Duration
settings *database.SettingsRegistry
} }
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService { func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
@@ -39,32 +34,17 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ
db: db, db: db,
cacheDir: cacheDir, cacheDir: cacheDir,
conversionTool: "/usr/bin/kepubify", conversionTool: "/usr/bin/kepubify",
conversionCacheTTL: defaultConversionCacheTTL, conversionCacheTTL: 24 * time.Hour,
} }
} }
// SetSettings wires the tunable settings registry. When wired, the cache TTL
// is read live on each conversion request.
func (s *ConversionService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
// cacheTTL returns the active conversion cache TTL.
func (s *ConversionService) cacheTTL() time.Duration {
if s.settings != nil {
return s.settings.ConversionCacheTTL()
}
if s.conversionCacheTTL > 0 {
return s.conversionCacheTTL
}
return defaultConversionCacheTTL
}
func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) { func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) {
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
FormatType: "kepub", FormatType: "kepub",
}) })
if err == nil && existing.FilePath.Valid { if err == nil && existing.FilePath.Valid {
if time.Since(existing.CreatedAt.Time) < s.cacheTTL() { if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL {
return &ConvertedKEPUB{ return &ConvertedKEPUB{
Path: existing.FilePath.String, Path: existing.FilePath.String,
SHA256: existing.FileSha256.String, SHA256: existing.FileSha256.String,
+1 -3
View File
@@ -65,7 +65,5 @@ func TestConversionServiceDefaults(t *testing.T) {
assert.NotNil(t, service) assert.NotNil(t, service)
assert.Equal(t, cacheDir, service.cacheDir) assert.Equal(t, cacheDir, service.cacheDir)
assert.Equal(t, "/usr/bin/kepubify", service.conversionTool) assert.Equal(t, "/usr/bin/kepubify", service.conversionTool)
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL field should be 24 hours") assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours")
// cacheTTL() must reflect the same default when no registry is wired.
assert.Equal(t, int64(24*3600*1000000000), service.cacheTTL().Nanoseconds(), "Default TTL accessor should return 24 hours")
} }
+1 -9
View File
@@ -176,17 +176,10 @@ func (w *Worker) GetActiveJobCount() int {
return count return count
} }
func NewWorker(numWorkers int, connManager *wsync.ConnectionManager) *Worker { func NewWorker(numWorkers int, connManager *wsync.ConnectionManager) *Worker {
return NewWorkerWithConfig(numWorkers, 100, connManager)
}
// NewWorkerWithConfig constructs a worker pool with the given worker count and
// job-queue capacity. Used at startup to source values from the settings
// registry.
func NewWorkerWithConfig(numWorkers, queueCap int, connManager *wsync.ConnectionManager) *Worker {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
w := &Worker{ w := &Worker{
jobQueue: make(chan *Job, queueCap), jobQueue: make(chan *Job, 100),
results: make(map[string]*JobResult), results: make(map[string]*JobResult),
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
@@ -1002,4 +995,3 @@ func (w *Worker) Shutdown() {
close(w.jobQueue) close(w.jobQueue)
w.wg.Wait() w.wg.Wait()
} }
+10 -27
View File
@@ -1,9 +1,8 @@
// Package setupstatus reports whether the application's initial setup has been // Package setupstatus reports whether the application's initial setup has been
// completed. Setup is considered complete when at least one admin user exists // completed. Setup is considered complete as soon as at least one admin user
// AND a non-empty base_url has been configured, regardless of how those were // exists, regardless of how that user was created (setup wizard, API, or a
// created (setup wizard, API, or a future CLI). This keeps the setup gate a // future CLI). This keeps the setup gate a derived property of real data
// derived property of real data rather than a manually-flipped flag that can // rather than a manually-flipped flag that can drift out of sync.
// drift out of sync.
package setupstatus package setupstatus
import ( import (
@@ -19,12 +18,6 @@ type AdminCounter interface {
CountAdmins(ctx context.Context) (int64, error) 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 ( var (
cacheMu sync.RWMutex cacheMu sync.RWMutex
cacheComplete bool = true cacheComplete bool = true
@@ -33,11 +26,10 @@ var (
) )
// IsSetupComplete reports whether setup is complete. Setup is complete when at // IsSetupComplete reports whether setup is complete. Setup is complete when at
// least one admin user exists AND base_url is configured. A short in-memory // least one admin user exists. A short in-memory cache avoids hammering the
// cache avoids hammering the database on every request. On a database error the // database on every request. On a database error the function fails open
// function fails open (returns true) so a transient outage does not lock users // (returns true) so a transient outage does not lock users out of the app.
// out of the app. func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLGetter) bool {
cacheMu.RLock() cacheMu.RLock()
if time.Now().Before(cacheExpiry) { if time.Now().Before(cacheExpiry) {
complete := cacheComplete complete := cacheComplete
@@ -46,20 +38,12 @@ func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLG
} }
cacheMu.RUnlock() cacheMu.RUnlock()
complete := true
count, err := q.CountAdmins(ctx) count, err := q.CountAdmins(ctx)
complete := true
if err == nil { if err == nil {
complete = count > 0 complete = count > 0
} }
if complete && baseURLGetter != nil {
baseURL, err := baseURLGetter(ctx)
if err == nil {
complete = baseURL != ""
}
}
cacheMu.Lock() cacheMu.Lock()
cacheComplete = complete cacheComplete = complete
cacheExpiry = time.Now().Add(cacheTTL) cacheExpiry = time.Now().Add(cacheTTL)
@@ -69,8 +53,7 @@ func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLG
// Invalidate clears the cached setup status so the next call to IsSetupComplete // 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 // re-reads from the database. Call this after any write that could change the
// admin user count (user creation, role promotion/demotion, user deletion) or // admin user count (user creation, role promotion/demotion, user deletion).
// the base_url configuration.
func Invalidate() { func Invalidate() {
cacheMu.Lock() cacheMu.Lock()
cacheComplete = true cacheComplete = true
+16 -63
View File
@@ -18,38 +18,8 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
// TombstoneTTL is the fallback retention for soft-deleted annotations when no
// settings registry is wired (e.g. in tests). It matches the historical value.
const TombstoneTTL = 30 * 24 * time.Hour const TombstoneTTL = 30 * 24 * time.Hour
type AnnotationService struct {
db *database.Queries
connMgr *ConnectionManager
settings *database.SettingsRegistry
}
func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
return &AnnotationService{db: db, connMgr: connMgr}
}
// SetSettings wires the tunable settings registry. When wired, the tombstone
// TTL is read live from the DB; otherwise the package const TombstoneTTL is
// used.
func (s *AnnotationService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
// tombstoneTTL returns the active tombstone retention window.
func (s *AnnotationService) tombstoneTTL() time.Duration {
if s.settings != nil {
return s.settings.TombstoneTTL()
}
return TombstoneTTL
}
// ActiveTombstoneTTL exposes the configured tombstone retention window for
// callers outside the sync package (e.g. kobo/koreader handlers) that need to
// compute cutoffs consistently with the service.
func (s *AnnotationService) ActiveTombstoneTTL() time.Duration { return s.tombstoneTTL() }
type SaveOutcome string type SaveOutcome string
const ( const (
@@ -59,6 +29,15 @@ const (
SaveOutcomeDeleted SaveOutcome = "deleted" SaveOutcomeDeleted SaveOutcome = "deleted"
) )
type AnnotationService struct {
db *database.Queries
connMgr *ConnectionManager
}
func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
return &AnnotationService{db: db, connMgr: connMgr}
}
type SaveHighlightRequest struct { type SaveHighlightRequest struct {
MediaItemID pgtype.UUID MediaItemID pgtype.UUID
UserID pgtype.UUID UserID pgtype.UUID
@@ -100,7 +79,7 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight
} }
if existing.Deleted.Bool { if existing.Deleted.Bool {
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() { if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
} }
return s.createHighlight(ctx, req, dedupKey) return s.createHighlight(ctx, req, dedupKey)
@@ -259,7 +238,7 @@ func (s *AnnotationService) TombstoneHighlightByID(
} }
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error { func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true} cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true}
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil { if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
return fmt.Errorf("purge highlight tombstones: %w", err) return fmt.Errorf("purge highlight tombstones: %w", err)
} }
@@ -272,13 +251,7 @@ func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
return nil return nil
} }
// StartDailyMaintenance launches a single background goroutine that runs all func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
// periodic cleanup tasks once every 24 hours: expired annotation tombstones,
// expired/revoked refresh tokens (retention follows the configured session
// duration), and expired OPDS tokens. Each task is independent; a failure in
// one is logged and does not skip the others. The returned CancelFunc stops the
// goroutine and the underlying ticker; it must be invoked on shutdown.
func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc {
ticker := time.NewTicker(24 * time.Hour) ticker := time.NewTicker(24 * time.Hour)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -289,7 +262,9 @@ func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc {
ticker.Stop() ticker.Stop()
return return
case <-ticker.C: case <-ticker.C:
s.runDailyMaintenance(ctx) if err := s.PurgeExpiredTombstones(ctx); err != nil {
log.Printf("AnnotationService: tombstone purge failed: %v", err)
}
} }
} }
}() }()
@@ -297,28 +272,6 @@ func (s *AnnotationService) StartDailyMaintenance() context.CancelFunc {
return cancel return cancel
} }
// runDailyMaintenance executes every periodic cleanup task. Tasks run
// sequentially under the single daily-tick goroutine so there is no added
// concurrency. All three queries only delete rows that are already unusable
// (expired or revoked), so this never logs out active sessions.
func (s *AnnotationService) runDailyMaintenance(ctx context.Context) {
if err := s.PurgeExpiredTombstones(ctx); err != nil {
log.Printf("maintenance: tombstone purge failed: %v", err)
}
if err := s.db.CleanupExpiredOpdsTokens(ctx); err != nil {
log.Printf("maintenance: OPDS token purge failed: %v", err)
}
// Refresh-token retention follows the configured session duration; re-read
// on every tick so live settings changes are honored. Guarded so unwired
// test paths simply skip cleanup (production always wires the registry).
if s.settings != nil {
retention := s.settings.SessionDuration().Seconds()
if err := s.db.CleanupExpiredRefreshTokens(ctx, retention); err != nil {
log.Printf("maintenance: refresh token purge failed: %v", err)
}
}
}
type SaveNoteRequest struct { type SaveNoteRequest struct {
MediaItemID pgtype.UUID MediaItemID pgtype.UUID
UserID pgtype.UUID UserID pgtype.UUID
@@ -504,7 +457,7 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
} }
if existing.Deleted.Bool { if existing.Deleted.Bool {
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() { if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
} }
return s.createBookmark(ctx, req, dedupKey) return s.createBookmark(ctx, req, dedupKey)
+2 -9
View File
@@ -74,18 +74,11 @@ type SyncQueueItem struct {
} }
func NewSyncQueueProcessor(db *database.Queries) *SyncQueueProcessor { func NewSyncQueueProcessor(db *database.Queries) *SyncQueueProcessor {
return NewSyncQueueProcessorWithConfig(db, 5*time.Second, 50)
}
// NewSyncQueueProcessorWithConfig constructs a processor with the given flush
// interval and batch size. Used at startup to source values from the settings
// registry.
func NewSyncQueueProcessorWithConfig(db *database.Queries, interval time.Duration, batchSize int) *SyncQueueProcessor {
return &SyncQueueProcessor{ return &SyncQueueProcessor{
db: db, db: db,
progressChan: make(chan *ProgressUpdate, 100), progressChan: make(chan *ProgressUpdate, 100),
interval: interval, interval: 5 * time.Second,
batchSize: batchSize, batchSize: 50,
} }
} }
+68 -85
View File
@@ -1,136 +1,119 @@
package templates package templates
templ Admin(user User, stats AdminStats) { templ Admin(user User) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<title>Admin Dashboard - Bookhoard</title> <title>Admin Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="admin" x-init="loadWatchStatus()" class="theme-{ user.Theme }"> <body x-data="admin" x-init="loadWatchStatus(); initializeScanWebSocket()" class="theme-tokyo-night">
@Header(user, "/admin") @Header(user, "/admin")
<main class="p-8"> <div class="flex min-h-screen bg-surface">
@AdminSidebar(user, "/admin")
<main class="flex-1 p-6 sm:p-8">
<div class="max-w-4xl"> <div class="max-w-4xl">
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center gap-3 mb-1"> <h1 class="text-2xl font-bold tracking-tight text-content mb-2">Dashboard</h1>
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);"> <p class="text-content-muted">Overview of your Bookhoard library and settings</p>
@Icon("grid", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Dashboard</h1>
</div> </div>
<p class="text-sm" style="color: var(--text-secondary)">Overview of your Bookhoard instance</p> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
</div> <div class="card p-6">
<!-- Stats Grid --> <div class="flex items-center space-x-3">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <div class="text-content-muted">@Icon("library", "h-7 w-7")</div>
<div class="stat-card"> <div>
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);"> <h3 class="font-semibold text-content">Library</h3>
@Icon("library", "h-4 w-4") <p class="text-content-muted text-sm">Manage your ebook collection</p>
<span class="text-xs font-semibold uppercase tracking-wide">Libraries</span>
</div>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.LibraryCount }</p>
</div>
<div class="stat-card">
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
@Icon("book", "h-4 w-4")
<span class="text-xs font-semibold uppercase tracking-wide">Books</span>
</div>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.MediaCount }</p>
</div>
<div class="stat-card">
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
@Icon("users", "h-4 w-4")
<span class="text-xs font-semibold uppercase tracking-wide">Users</span>
</div>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.UserCount }</p>
</div>
<div class="stat-card">
<div class="flex items-center gap-2 mb-2" style="color: var(--text-secondary);">
@Icon("device", "h-4 w-4")
<span class="text-xs font-semibold uppercase tracking-wide">Devices</span>
</div>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.DeviceCount }</p>
</div> </div>
</div> </div>
<!-- Watch Status --> <a href="/" class="btn btn-secondary mt-4 text-sm">View Library</a>
<div class="stat-card mb-6">
<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 class="flex-1">
<h3 class="font-semibold" style="color: var(--text-primary)">File Watcher</h3>
<p class="text-sm" style="color: var(--text-secondary)">Auto-detects new files in library folders</p>
</div> </div>
<div class="text-right text-sm" style="color: var(--text-secondary)"> <div class="card p-6">
<span class="inline-block w-2 h-2 rounded-full mr-2" style="background-color: var(--status-success);"></span> <div class="flex items-center space-x-3">
<div class="text-content-muted">@Icon("sync", "h-7 w-7")</div>
<div>
<h3 class="font-semibold text-content">Scan Watch Status</h3>
<p class="text-content-muted text-sm">Auto-detecting new files</p>
</div>
</div>
<div id="watch-status" class="mt-4 text-sm text-content-muted">
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
Watching <span id="watch-count">0</span> libraries Watching <span id="watch-count">0</span> libraries
</div> </div>
</div> </div>
</div> </div>
<!-- Quick Actions -->
<div class="card p-6"> <div class="card p-6">
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3> <h3 class="text-xl font-semibold mb-4 text-content">Quick Actions</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <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"> <button @click="scanAllLibraries()" class="btn btn-primary p-4 text-left">
<span class="flex items-center gap-2 font-medium"> <div class="font-medium">Rescan Library</div>
@Icon("refresh", "h-5 w-5") <div class="text-content-muted text-sm">Re-scan existing files and fix metadata</div>
Scan All Libraries
</span>
<span class="text-xs font-normal opacity-80">Re-scan existing files and detect new items</span>
</button> </button>
<a href="/admin/library" class="btn btn-secondary py-4 flex-col items-start gap-1"> <a href="/admin/library" class="btn btn-secondary p-4 text-left block">
<span class="flex items-center gap-2 font-medium"> <div class="font-medium">Manage Libraries and Folders</div>
@Icon("library", "h-5 w-5") <div class="text-content-muted text-sm">Add or remove libraries and scan directories</div>
Manage Libraries
</span>
<span class="text-xs font-normal opacity-80">Add or remove libraries and folders</span>
</a> </a>
</div> </div>
</div> </div>
<!-- Scan Progress Section --> <!-- 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 id="scan-progress-container" class="card mt-6 opacity-0 -translate-y-2.5 transition-all duration-300 ease-out">
<div class="flex justify-between items-center mb-4"> <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)"> <h3 class="text-lg font-semibold text-content flex items-center gap-2">
@Icon("refresh", "h-5 w-5") @Icon("library", "h-5 w-5")
Scanning Libraries Scanning Libraries
</h3> </h3>
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close"> <button @click="hideScanProgress()" class="p-2 hover:bg-surface-hover rounded">
@Icon("close", "h-5 w-5") @Icon("close", "h-4 w-4")
</button> </button>
</div> </div>
<!-- Overall Progress -->
<div class="mb-4"> <div class="mb-4">
<div class="flex justify-between text-sm mb-2"> <div class="flex justify-between text-sm mb-2">
<span style="color: var(--text-secondary)">Overall Progress</span> <span class="text-content-muted">Overall Progress</span>
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span> <span id="scan-progress-text" class="text-content">0%</span>
</div> </div>
<div class="w-full rounded-full h-3" style="background-color: var(--surface-hover);"> <div class="w-full bg-surface-hover rounded-full h-3">
<div <div
id="scan-progress-bar" id="scan-progress-bar"
class="h-3 rounded-full transition-all duration-500" class="h-3 rounded-full transition-all duration-500 bg-brand"
style="width: 0%; background-color: var(--accent);" style="width: 0%;"
></div> ></div>
</div> </div>
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)"> <div id="scan-status" class="text-sm mt-2 text-content-muted">
Starting scan... Starting scan...
</div> </div>
</div> </div>
<div id="library-progress-list" class="space-y-3"></div> <!-- Per-Library Progress -->
<div id="scan-results" class="hidden mt-6 p-4 rounded-xl border" style="background-color: var(--bg-primary); border-color: var(--border);"> <div id="library-progress-list" class="space-y-3">
<h4 class="font-semibold mb-2 flex items-center gap-2" style="color: var(--status-success);"> <!-- Dynamically populated -->
@Icon("check-circle", "h-5 w-5") </div>
Scan Complete! <!-- Results Summary -->
</h4> <div id="scan-results" class="hidden mt-6 p-4 rounded-lg border border-line bg-surface">
<div id="scan-results-content" style="color: var(--text-secondary)"></div> <h4 class="font-semibold mb-2 text-content flex items-center gap-2">@Icon("check-circle", "h-4 w-4") Scan Complete!</h4>
<div id="scan-results-content" class="text-content-muted">
<!-- Results populated by JS -->
</div>
<div class="mt-4 flex gap-2"> <div class="mt-4 flex gap-2">
<button @click="window.location.reload()" class="btn btn-primary">Refresh to View Books</button> <button
<button @click="hideScanProgress()" class="btn btn-secondary">Dismiss</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>
</div> </div>
</div> </div>
</main> </main>
</div>
</body> </body>
</html> </html>
} }
+108 -370
View File
@@ -6,414 +6,152 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<title>Library Management - Bookhoard</title> <title>Library Management - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
@Header(user, "/admin/library") @Header(user, "/admin/library")
<main class="p-8"> <div class="flex min-h-screen bg-surface">
<div class="max-w-4xl"> @AdminSidebar(user, "/admin/library")
<main class="flex-1 p-6 sm:p-8">
<div class="w-full">
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between gap-4 flex-wrap mb-4"> <div class="flex items-center justify-between mb-4">
<div> <a href="/admin" class="btn btn-secondary font-medium">
<div class="flex items-center gap-3 mb-1"> @Icon("arrow-left", "h-4 w-4")
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);"> Back to Dashboard
@Icon("library", "h-5 w-5") </a>
</span> <button data-action="show-create-modal" class="btn btn-primary font-medium">
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Libraries</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Manage media libraries, folders, and scanning</p>
</div>
<button
onclick="document.getElementById('create-library-modal').classList.remove('hidden')"
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4") @Icon("plus", "h-4 w-4")
Create Library Create Library
</button> </button>
</div> </div>
<h1 class="text-2xl font-bold tracking-tight text-content mb-2">Library Management</h1>
<p class="text-content-muted">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">
<h3 class="text-xl font-semibold mb-6 text-content">Libraries</h3>
<p class="text-content-muted mb-4">Manage media libraries and their folders</p>
<div id="libraries-list" class="space-y-3 mb-6">
if len(libraries) == 0 {
<p class="text-content-muted text-center py-8">
No libraries yet. Create your first library to get started.
</p>
} else {
for _, library := range libraries {
<div class="p-4 border border-line rounded-lg bg-surface">
<div class="flex justify-between items-start mb-2">
<div>
<h4 class="font-semibold text-content">{ library.Name }</h4>
if library.Description != "" {
<p class="text-sm text-content-muted">{ library.Description }</p>
}
<span class="badge bg-brand text-white">
{ library.TypeName }
</span>
</div>
<div class="flex space-x-2">
<button data-library-id={ library.ID } data-action="show-folders" class="btn btn-secondary text-xs">Folders</button>
<button data-library-id={ library.ID } data-action="edit" class="btn btn-primary text-xs">Edit</button>
<button data-library-id={ library.ID } data-action="delete" class="btn btn-danger text-xs">Delete</button>
</div>
</div>
<div id={ "library-folders-" + library.ID } class="hidden mt-3 space-y-2"></div>
</div>
}
}
</div>
</div>
<!-- User Library Visibility Section -->
<div class="card p-6">
<h3 class="text-xl font-semibold mb-6 text-content">Library Visibility</h3>
<p class="text-content-muted 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">
<h3 class="text-xl font-semibold mb-6 text-content">User Library Access</h3>
<p class="text-content-muted mb-4">Manage individual user access to specific libraries</p>
<div class="mb-4">
<select id="user-select" onchange="loadUserVisibility()" class="input">
<option value="">Select a user...</option>
for _, user := range users {
<option value="{ user.ID }">{ user.Username } ({ user.Email })</option>
}
</select>
</div>
<div id="user-libraries" class="space-y-3">
<!-- User library checkboxes will be loaded here -->
</div> </div>
<div id="libraries-container">
@LibraryList(user, libraries, users)
</div> </div>
</div> </div>
</main> </main>
<!-- Create / Edit 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" 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" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div> </div>
<form <!-- Create Library Modal -->
hx-post="/admin/library/create" <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);">
hx-target="#libraries-container" <div class="card p-6 w-full max-w-md mx-4">
hx-swap="innerHTML" <div class="flex justify-between items-center mb-6">
onsubmit="document.getElementById('create-library-modal').classList.add('hidden')" <h2 class="text-xl font-bold text-content">Create Library</h2>
> <button type="button" data-action="hide-create-modal" class="p-2 hover:bg-surface-hover rounded text-content">@Icon("close", "h-4 w-4")</button>
</div>
<form id="create-library-form">
<input type="hidden" id="library-id" name="id"/>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label> <label class="block text-sm font-medium mb-2 text-content">Library Name</label>
<input type="text" name="name" placeholder="My Ebook Library" class="input" required/> <input type="text" name="name" placeholder="My Ebook Library" class="input" required/>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label> <label class="block text-sm font-medium mb-2 text-content">Description</label>
<textarea name="description" placeholder="Optional description" rows="3" class="input"></textarea> <textarea name="description" placeholder="Optional description" rows="3" class="input"></textarea>
</div> </div>
<div class="mb-6"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label> <label class="block text-sm font-medium mb-2 text-content">Library Type</label>
<select name="type" class="input" required> <select name="type" class="input" required>
<option value="ebooks">Ebooks</option> <option value="ebooks">📚 Ebooks</option>
<option value="comics">Comics</option> <option value="comics">📖 Comics</option>
<option value="manga">Manga</option> <option value="manga">🗾 Manga</option>
</select> </select>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button> <button type="button" data-action="hide-create-modal" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">Create</button>
@Icon("plus", "h-4 w-4")
Create
</button>
</div>
</form>
</div>
</div>
<!-- Edit Library Modal -->
<div id="edit-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" 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)">Edit Library</h2>
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<form id="edit-library-form" hx-target="#libraries-container" hx-swap="innerHTML" onsubmit="document.getElementById('edit-library-modal').classList.add('hidden')">
<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" id="edit-library-name" class="input" required/>
</div>
<div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
<textarea name="description" id="edit-library-desc" rows="3" class="input"></textarea>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('edit-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">
@Icon("save", "h-4 w-4")
Save
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Folder Browser Modal --> <!-- 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 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 p-6 w-full max-w-lg mx-4" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2> <h2 class="text-xl font-bold text-content">Browse Folders</h2>
<button type="button" onclick="document.getElementById('folder-browser-modal').classList.add('hidden')" class="icon-btn" aria-label="Close"> <button type="button" data-action="browse-cancel" class="p-2 hover:bg-surface-hover rounded text-content">@Icon("close", "h-4 w-4")</button>
@Icon("close", "h-5 w-5")
</button>
</div> </div>
<div id="folder-browser-content"></div> <div id="folder-browser-content">
<!-- Directory listings will be rendered here -->
</div> </div>
</div> </div>
<!-- Delete Library Modal --> </div>
<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);"> <!-- Delete Library Confirmation Modal -->
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);"> <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 p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2> <h2 class="text-xl font-bold text-content">Delete Library</h2>
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close"> <button type="button" data-action="hide-delete-modal" class="p-2 hover:bg-surface-hover rounded text-content">@Icon("close", "h-4 w-4")</button>
@Icon("close", "h-5 w-5") </div>
</button> <div id="delete-modal-content" class="mb-6 text-content">
<!-- Dynamic content will be injected here -->
</div> </div>
<p class="mb-6" style="color: var(--text-primary)">
Are you sure you want to delete <strong id="delete-library-name"></strong>?
This will remove the library and all its folder mappings. Media files will not be deleted.
</p>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button> <button type="button" data-action="hide-delete-modal" class="btn btn-secondary">Cancel</button>
<button type="button" id="delete-library-confirm" class="btn btn-danger"> <button type="button" data-action="confirm-delete" class="btn btn-danger">Delete</button>
@Icon("trash", "h-4 w-4")
Delete
</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script src="/static/htmx.min.js"></script>
function toggleLibraryPanel(btn) {
var libId = btn.dataset.libId;
var panel = document.getElementById('library-panel-' + libId);
if (panel.innerHTML.trim() !== '') {
panel.innerHTML = '';
} else {
htmx.ajax('GET', '/admin/library/' + libId + '/panel', {
target: '#library-panel-' + libId,
swap: 'innerHTML'
});
}
}
function openEditModal(id, name, description) {
document.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);
document.getElementById('edit-library-name').value = name;
document.getElementById('edit-library-desc').value = description;
htmx.process(document.getElementById('edit-library-form'));
document.getElementById('edit-library-modal').classList.remove('hidden');
}
function openDeleteModal(id, name) {
const btn = document.getElementById('delete-library-confirm');
btn.setAttribute('hx-delete', '/admin/library/' + id);
btn.setAttribute('hx-target', '#libraries-container');
btn.setAttribute('hx-swap', 'innerHTML');
document.getElementById('delete-library-name').textContent = name;
htmx.process(btn);
document.getElementById('delete-library-modal').classList.remove('hidden');
}
function openFolderBrowser(targetInputId, libraryId) {
const content = document.getElementById('folder-browser-content');
content.setAttribute('hx-get', '/admin/library/browse');
content.setAttribute('hx-vals', '{"target_input": "' + targetInputId + '", "library_id": "' + libraryId + '"}');
content.setAttribute('hx-trigger', 'load');
htmx.process(content);
document.getElementById('folder-browser-modal').classList.remove('hidden');
}
</script>
</body> </body>
</html> </html>
} }
templ LibraryList(user User, libraries []LibraryData, users []User) {
if len(libraries) == 0 {
<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("library", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Libraries Yet</h3>
<p class="mb-4 text-sm" style="color: var(--text-secondary)">Create your first library to get started</p>
<button onclick="document.getElementById('create-library-modal').classList.remove('hidden')" class="btn btn-primary">Create Your First Library</button>
</div>
} else {
<div class="space-y-4">
for _, library := range libraries {
<div class="card overflow-hidden">
<div class="flex items-center justify-between gap-4 p-5">
<div class="flex items-center gap-3 min-w-0 flex-1">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("library", "h-5 w-5")
</span>
<div class="min-w-0">
<h3 class="font-semibold truncate" style="color: var(--text-primary)">{ library.Name }</h3>
if library.Description != "" {
<p class="text-sm truncate" style="color: var(--text-secondary)">{ library.Description }</p>
}
</div>
<span class="chip shrink-0">
@Icon("tag", "h-3 w-3")
{ library.TypeName }
</span>
if library.FolderCount > 0 {
<span class="chip shrink-0">
@Icon("folder", "h-3 w-3")
{ library.FolderCount } folders
</span>
}
</div>
<div class="flex items-center gap-2 shrink-0">
<button
hx-post={ "/api/libraries/" + library.ID + "/scan" }
hx-vals='{"force": "true"}'
hx-target="#scan-indicator"
hx-swap="innerHTML"
class="btn btn-secondary text-xs px-3 py-1.5"
>
@Icon("refresh", "h-4 w-4")
Scan
</button>
<button
data-lib-id={ library.ID }
onclick="toggleLibraryPanel(this)"
class="btn btn-secondary text-xs px-3 py-1.5"
>
@Icon("chevron-down", "h-4 w-4")
Manage
</button>
</div>
</div>
<div id={ "library-panel-" + library.ID }></div>
</div>
}
</div>
}
<div id="scan-indicator"></div>
}
templ LibraryPanel(user User, libraryID string, library LibraryData, folders []FolderData, users []User, visibility []UserVisibilityData, issueCount int) {
<div class="border-t p-5 space-y-6" style="border-color: var(--border);">
<!-- Folders -->
<div>
<div class="flex items-center gap-2 mb-3">
@Icon("folder", "h-4 w-4 shrink-0")
<h4 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Folders</h4>
</div>
if len(folders) == 0 {
<p class="text-sm mb-3" style="color: var(--text-secondary)">No folders configured. Add a folder to enable scanning.</p>
} else {
<div class="space-y-2 mb-3">
for _, folder := range folders {
<div class="flex items-center justify-between gap-2 p-2 rounded-lg" style="background-color: var(--bg-primary);">
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ folder.FolderPath }</code>
<button
class="icon-btn h-7 w-7 shrink-0"
hx-delete={ "/admin/library/" + libraryID + "/folders" }
hx-vals={ `{"folder_path": "` + folder.FolderPath + `"}` }
hx-target={ "#library-panel-" + libraryID }
hx-swap="innerHTML"
hx-confirm="Remove this folder from the library?"
>
@Icon("trash", "h-3.5 w-3.5")
</button>
</div>
}
</div>
}
<form class="flex gap-2" hx-post={ "/admin/library/" + libraryID + "/folders" } hx-target={ "#library-panel-" + libraryID } hx-swap="innerHTML">
<input
type="text"
name="folder_path"
id={ "folder-input-" + libraryID }
placeholder="/path/to/books"
class="input flex-1"
required
/>
<button
type="button"
data-library-id={ libraryID }
data-target-input={ "folder-input-" + libraryID }
onclick="openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)"
class="btn btn-secondary text-sm shrink-0"
>
@Icon("folder", "h-4 w-4")
Browse
</button>
<button type="submit" class="btn btn-primary text-sm shrink-0">
@Icon("plus", "h-4 w-4")
Add
</button>
</form>
</div>
<!-- User Visibility -->
if len(users) > 0 {
<div>
<div class="flex items-center gap-2 mb-3">
@Icon("users", "h-4 w-4 shrink-0")
<h4 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">User Access</h4>
</div>
<div class="space-y-1">
for _, u := range users {
<label class="flex items-center gap-3 cursor-pointer p-2 rounded-lg transition-colors hover:bg-surface-hover">
<input
type="checkbox"
class="w-4 h-4 rounded"
name="is_visible"
value="true"
checked?={ isUserVisible(u.ID, visibility) }
hx-post={ "/admin/library/" + libraryID + "/visibility" }
hx-vals={ `{"user_id": "` + u.ID + `"}` }
hx-trigger="change"
hx-target={ "#library-panel-" + libraryID }
hx-swap="innerHTML"
/>
<span class="text-sm" style="color: var(--text-primary)">{ u.Username }</span>
<span class="text-xs" style="color: var(--text-secondary)">{ u.Email }</span>
</label>
}
</div>
</div>
}
<!-- Processing Issues -->
if issueCount > 0 {
<div>
<a href={ "/admin/libraries/" + libraryID + "/issues" } class="flex items-center gap-2 text-sm" style="color: var(--status-warning);">
@Icon("alert", "h-4 w-4")
if issueCount == 1 {
<span>1 processing issue</span>
} else {
<span>{ issueCount } processing issues</span>
}
@Icon("chevron-right", "h-4 w-4")
</a>
</div>
}
<!-- Actions -->
<div class="flex gap-2 pt-2 border-t" style="border-color: var(--border);">
<button
data-edit-id={ libraryID }
data-edit-name={ library.Name }
data-edit-desc={ library.Description }
onclick="openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)"
class="btn btn-secondary text-sm"
>
@Icon("edit", "h-4 w-4")
Edit Details
</button>
<button
data-delete-id={ libraryID }
data-delete-name={ library.Name }
onclick="openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)"
class="btn btn-danger text-sm"
>
@Icon("trash", "h-4 w-4")
Delete Library
</button>
</div>
</div>
}
templ FolderBrowserContent(currentPath string, parentPath string, entries []DirEntry, targetInput string, libraryID string) {
<div>
<div class="flex items-center gap-2 mb-3 p-2 rounded-lg" style="background-color: var(--bg-primary);">
@Icon("folder", "h-4 w-4 shrink-0")
<code class="text-xs flex-1 truncate" style="color: var(--text-primary)">{ currentPath }</code>
</div>
if parentPath != "" {
<button
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover"
style="color: var(--text-secondary);"
hx-get={ "/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID }
hx-target="#folder-browser-content"
hx-swap="innerHTML"
>
@Icon("arrow-left", "h-4 w-4")
<span>..</span>
</button>
}
<div class="space-y-1 max-h-64 overflow-y-auto">
for _, entry := range entries {
<button
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors hover:bg-surface-hover"
style="color: var(--text-primary);"
hx-get={ "/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID }
hx-target="#folder-browser-content"
hx-swap="innerHTML"
>
@Icon("folder", "h-4 w-4 shrink-0")
<span class="truncate">{ entry.Name }</span>
</button>
}
</div>
<div class="mt-4 flex justify-end">
<button
type="button"
data-target-input={ targetInput }
data-current-path={ currentPath }
onclick="document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')"
class="btn btn-primary text-sm"
>
@Icon("check", "h-4 w-4")
Select This Folder
</button>
</div>
</div>
}
File diff suppressed because it is too large Load Diff
+38 -57
View File
@@ -1,3 +1,4 @@
package templates package templates
templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) { templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) {
@@ -7,98 +8,79 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<title>Processing Issues - Bookhoard</title> <title>Processing Issues - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
@Header(user, "/admin/library") @Header(user, "/admin/libraries/"+libraryID)
<main class="p-8"> <main class="flex-1 p-6 sm:p-8">
<div class="mx-auto max-w-4xl">
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4">
<div> <div>
<div class="flex items-center gap-3 mb-1"> <h1 class="text-2xl font-bold tracking-tight text-content mb-2">Processing Issues</h1>
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);"> <p class="text-content-muted">Items that couldn't be processed in this library</p>
@Icon("alert", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Processing Issues</h1>
</div> </div>
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p> <a href="/admin/libraries/{ libraryID }" class="btn btn-secondary">
@Icon("arrow-left", "h-4 w-4")
Back to Library
</a>
</div> </div>
</div> </div>
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
if stats.ErrorCount > 0 { if stats.ErrorCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-danger);"> <div class="card p-6 border-l-4 border-red-500">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);"> <h3 class="text-lg font-semibold text-red-600 mb-2">Errors</h3>
@Icon("x-circle", "h-5 w-5") <p class="text-3xl font-bold text-content">{ stats.ErrorCount }</p>
<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> </div>
} }
if stats.WarningCount > 0 { if stats.WarningCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-warning);"> <div class="card p-6 border-l-4 border-yellow-500">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);"> <h3 class="text-lg font-semibold text-yellow-600 mb-2">Warnings</h3>
@Icon("alert", "h-5 w-5") <p class="text-3xl font-bold text-content">{ stats.WarningCount }</p>
<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> </div>
} }
if stats.InfoCount > 0 { if stats.InfoCount > 0 {
<div class="stat-card" style="border-left: 4px solid var(--status-info);"> <div class="card p-6 border-l-4 border-blue-500">
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);"> <h3 class="text-lg font-semibold text-blue-600 mb-2">Info</h3>
@Icon("info", "h-5 w-5") <p class="text-3xl font-bold text-content">{ stats.InfoCount }</p>
<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>
} }
</div> </div>
} }
if len(issues) == 0 { if len(issues) == 0 {
<div class="card p-8 text-center"> <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);"> <p class="text-content-muted">No processing issues found for this library.</p>
@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> </div>
} else { } else {
<!-- Issues List --> <!-- Issues List -->
<div class="space-y-4"> <div class="space-y-4">
for _, issue := range issues { for _, issue := range issues {
<div class="card p-6" id={ "issue-" + issue.ID }> <div class="card p-6">
<div class="flex justify-between items-start gap-4 mb-4"> <div class="flex justify-between items-start mb-4">
<div class="flex-1 min-w-0"> <div class="flex-1">
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4> <h4 class="text-lg font-semibold mb-2 text-content">{ issue.Title }</h4>
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p> <p class="text-content mb-3">{ issue.IssueDescription }</p>
<div class="text-sm space-y-1" style="color: var(--text-secondary)"> <div class="text-sm text-content-muted space-y-1">
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p> <p><strong>Type:</strong> { issue.IssueType }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p> <p><strong>Format:</strong> { issue.FormatGroup }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p> <p><strong>File:</strong> { issue.FilePath }</p>
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p> <p><strong>Library:</strong> { issue.LibraryTypeName }</p>
</div> </div>
</div> </div>
<div class="ml-2 shrink-0"> <div class="ml-4">
if issue.Severity == "error" { <span
<span class="badge status-failed">{ issue.Severity }</span> class="inline-block px-3 py-1 text-sm rounded-full font-medium bg-brand text-white"
} else if issue.Severity == "warning" { >
<span class="badge status-pending">{ issue.Severity }</span> { issue.Severity }
} else { </span>
<span class="badge status-processing">{ issue.Severity }</span>
}
</div> </div>
</div> </div>
<div class="flex gap-3 mt-4"> <div class="flex gap-3 mt-4">
if issue.Severity == "warning" || issue.Severity == "info" { if issue.Severity == "warning" || issue.Severity == "info" {
<button <button
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" } @click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
hx-target={ "#issue-" + issue.ID }
hx-swap="outerHTML"
hx-confirm="Dismiss this issue?"
class="btn btn-secondary text-sm" class="btn btn-secondary text-sm"
> >
@Icon("close", "h-4 w-4")
Dismiss Dismiss
</button> </button>
} }
@@ -107,7 +89,6 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
} }
</div> </div>
} }
</div>
</main> </main>
</body> </body>
</html> </html>
+60 -184
View File
@@ -1,6 +1,7 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -29,23 +30,23 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Processing Issues - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Processing Issues - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"processingIssues\" x-init=\"initializeProcessingIssues('{ libraryID }')\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Header(user, "/admin/library").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Header(user, "/admin/libraries/"+libraryID).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"mx-auto max-w-4xl\"><div class=\"mb-8\"><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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"flex-1 p-6 sm:p-8\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><div><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">Processing Issues</h1><p class=\"text-content-muted\">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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Back to Library</a></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -55,315 +56,190 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if stats.ErrorCount > 0 { if stats.ErrorCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"card p-6 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 text-content\">")
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, 6, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 33, Col: 69}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if stats.WarningCount > 0 { if stats.WarningCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"card p-6 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 text-content\">")
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, 9, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 39, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if stats.InfoCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"card p-6 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 text-content\">")
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: 45, Col: 68}
}
_, 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, "</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if stats.InfoCount > 0 { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<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, 12, "<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: 54, 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, 13, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if len(issues) == 0 { if len(issues) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card p-8 text-center\"><p class=\"text-content-muted\">No processing issues found for this library.</p></div>")
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, 16, "</span><p class=\"text-sm\" style=\"color: var(--text-secondary)\">No processing issues found for this library.</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<!-- Issues List --> <div class=\"space-y-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<!-- Issues List --> <div class=\"space-y-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, issue := range issues { for _, issue := range issues {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card p-6\" id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"card p-6\"><div class=\"flex justify-between items-start mb-4\"><div class=\"flex-1\"><h4 class=\"text-lg font-semibold mb-2 text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 61, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"><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)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h4><p class=\"text-content mb-3\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 62, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p><div class=\"text-sm text-content-muted space-y-1\"><p><strong>Type:</strong> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 64, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p><p><strong>Format:</strong> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 65, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p><p><strong>File:</strong> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 66, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p><p><strong>Library:</strong> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 67, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p></div></div><div class=\"ml-4\"><span class=\"inline-block px-3 py-1 text-sm rounded-full font-medium bg-brand text-white\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149} 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p></div></div><div class=\"ml-2 shrink-0\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></div></div><div class=\"flex gap-3 mt-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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_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: 84, Col: 62}
}
_, 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, 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_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: 86, Col: 63}
}
_, 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, 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_Var14 string
templ_7745c5c3_Var14, 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: 88, Col: 66}
}
_, 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, 31, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></div><div class=\"flex gap-3 mt-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if issue.Severity == "warning" || issue.Severity == "info" { if issue.Severity == "warning" || issue.Severity == "info" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button hx-post=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button @click=\"dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')\" class=\"btn btn-secondary text-sm\">Dismiss</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 113}
}
_, 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, 34, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 43}
}
_, 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, 35, "\" hx-swap=\"outerHTML\" hx-confirm=\"Dismiss this issue?\" 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, 36, "Dismiss</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></main></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</main></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+29 -181
View File
@@ -1,8 +1,6 @@
package templates package templates
import "fmt" templ AdminSettings(user User, systemConfig map[string]string, errorMessage string) {
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -10,35 +8,33 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
<title>System Settings - Bookhoard</title> <title>System Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }" x-data="adminSettings">
@Header(user, "/admin/settings") @Header(user, "/admin/settings")
<main class="p-8"> <div class="flex min-h-screen bg-surface">
@AdminSidebar(user, "/admin/settings")
<main class="flex-1 p-6 sm:p-8">
<div class="max-w-4xl"> <div class="max-w-4xl">
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center gap-3 mb-1"> <div class="flex items-center justify-between mb-4">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);"> <a href="/admin" class="btn btn-secondary font-medium">
@Icon("gear", "h-5 w-5") @Icon("arrow-left", "h-4 w-4")
</span> Back to Dashboard
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">System Settings</h1> </a>
</div> </div>
<p class="text-sm" style="color: var(--text-secondary)">Configure your Bookhoard instance</p> <h1 class="text-2xl font-bold tracking-tight text-content mb-2">System Settings</h1>
<p class="text-content-muted">Configure your Bookhoard instance</p>
</div> </div>
if errorMessage != "" { if 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);"> <div class="mb-6 p-4 rounded-lg border border-line bg-surface-raised text-danger">
@Icon("alert", "h-5 w-5 shrink-0 mt-0.5") { errorMessage }
<span>{ errorMessage }</span>
</div> </div>
} }
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML"> <form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<div class="card p-6"> <div class="card p-6">
<div class="flex items-center gap-2 mb-6"> <h3 class="text-xl font-semibold mb-6 text-content">Base URL</h3>
@Icon("globe", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Base URL</h3>
</div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Base URL</label> <label class="block text-sm font-medium mb-2 text-content">Base URL</label>
<input <input
type="url" type="url"
name="base_url" name="base_url"
@@ -47,22 +43,18 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
class="input" class="input"
required 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> <p class="text-sm mt-1 text-content-muted">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary font-medium">
@Icon("save", "h-4 w-4")
Save Settings Save Settings
</button> </button>
</div> </div>
</div> </div>
<div class="mt-6 card p-6"> <div class="mt-8 card p-6">
<div class="flex items-center gap-2 mb-6"> <h3 class="text-xl font-semibold mb-6 text-content">System Defaults</h3>
@Icon("clock", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">System Defaults</h3>
</div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default Timezone</label> <label class="block text-sm font-medium mb-2 text-content">Default Timezone</label>
<select <select
name="default_timezone" name="default_timezone"
id="default_timezone" id="default_timezone"
@@ -93,165 +85,21 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
<option value="Australia/Sydney" selected?={ systemConfig["default_timezone"] == "Australia/Sydney" }>Australian Eastern (UTC+10/+11)</option> <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> <option value="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>New Zealand (UTC+12/+13)</option>
</select> </select>
<p class="text-sm mt-2" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p> <p class="text-sm mt-1 text-content-muted">Default timezone for users who haven't set their own.</p>
</div> </div>
</div> </div>
</form> </form>
<div class="mt-6 card p-6"> <div class="mt-8 card p-6">
<div class="flex items-center gap-2 mb-4"> <h3 class="text-xl font-semibold mb-4 text-content">URL Paths</h3>
@Icon("external", "h-5 w-5 shrink-0") <div class="space-y-2 text-sm text-content-muted">
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">URL Paths</h3> <p><strong class="text-content">OPDS:</strong> { systemConfig["base_url"] }/opds</p>
</div> <p><strong class="text-content">API:</strong> { systemConfig["base_url"] }/api</p>
<div class="space-y-2 text-sm" style="color: var(--text-secondary);"> <p><strong class="text-content">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> </div>
@ScanSettingsSection(scanSettings)
@TunableSettingsSection(liveGroups, false)
@TunableSettingsSection(restartGroups, true)
</div> </div>
</main> </main>
</div>
</body> </body>
</html> </html>
} }
templ ScanSettingsSection(scanSettings ScanSettingsData) {
<div id="scan-settings-section" class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-6">
@Icon("refresh", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
</div>
<form
hx-put="/admin/settings/scan"
hx-target="#scan-settings-section"
hx-swap="outerHTML"
>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)">Auto-Scan</label>
<p class="text-sm" style="color: var(--text-secondary)">Watch libraries for file changes on startup</p>
</div>
<label class="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
name="auto_scan_enabled"
value="true"
checked?={ scanSettings.AutoScanEnabled }
class="sr-only peer"
/>
<div class="w-11 h-6 rounded-full peer peer-checked:bg-brand transition-colors" style="background-color: color-mix(in srgb, var(--text-primary) 15%, transparent);"></div>
<div class="absolute left-0.5 top-0.5 bg-white rounded-full w-5 h-5 transition-transform peer-checked:translate-x-5"></div>
</label>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Scan Interval (seconds)</label>
<input
type="number"
name="scan_poll_interval_seconds"
value={ fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds) }
min="1"
max="3600"
class="input"
required
/>
<p class="text-sm mt-2" style="color: var(--text-secondary)">How often to poll libraries for changes (13600 seconds). Default: 60.</p>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
@Icon("save", "h-4 w-4")
Save Scan Settings
</button>
</div>
</div>
</form>
</div>
}
// TunableSettingsSection renders the editable tunables for a given bucket
// (live vs restart-required). Within the card, settings are clustered into
// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits").
templ TunableSettingsSection(groups []SettingGroup, restartRequired bool) {
<div class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-2">
if restartRequired {
@Icon("alert", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Tunable Settings Restart Required</h3>
} else {
@Icon("settings", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Tunable Settings Live</h3>
}
</div>
if restartRequired {
<p class="text-sm mb-4" style="color: var(--status-warning)">Changes are saved immediately but only take effect after the server restarts.</p>
} else {
<p class="text-sm mb-4" style="color: var(--text-secondary)">Changes apply immediately no restart needed.</p>
}
for _, g := range groups {
<div class="mt-5 first:mt-0">
<h4 class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)">{ g.Name }</h4>
<div>
for _, e := range g.Entries {
@TunableSettingRow(e)
}
</div>
</div>
}
</div>
}
// TunableSettingRow renders a single editable setting as an inline HTMX form.
templ TunableSettingRow(e SettingEntry) {
<div class="flex flex-col sm:flex-row sm:items-center gap-3 py-3" style="border-top: 1px solid color-mix(in srgb, var(--text-primary) 7%, transparent);">
<div class="flex-1 min-w-0">
<label class="block text-sm font-medium" style="color: var(--text-primary)">{ e.Description }</label>
if !e.IsDefault {
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ e.Key } modified from default</p>
} else {
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ e.Key }</p>
}
</div>
<form
class="flex items-center gap-2 shrink-0"
hx-put="/admin/settings/tunable"
hx-target={ "#status-" + e.Key }
hx-swap="innerHTML"
hx-disinherit="*"
>
<input type="hidden" name="key" value={ e.Key }/>
if e.Type == "bool" {
<select name="value" class="input py-1.5 text-sm w-28">
<option value="true" selected?={ e.Value == "true" }>Yes</option>
<option value="false" selected?={ e.Value != "true" }>No</option>
</select>
} else if e.Type == "int" {
<input
type="number"
name="value"
value={ e.Value }
if e.Min != "" {
min={ e.Min }
}
if e.Max != "" {
max={ e.Max }
}
class="input py-1.5 text-sm w-32"
/>
} else {
<input
type="text"
name="value"
value={ e.Value }
class="input py-1.5 text-sm w-40"
/>
}
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm">
@Icon("save", "h-3.5 w-3.5")
Save
</button>
</form>
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
</div>
}
+73 -529
View File
@@ -8,9 +8,7 @@ package templates
import "github.com/a-h/templ" import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime" import templruntime "github.com/a-h/templ/runtime"
import "fmt" func AdminSettings(user User, systemConfig map[string]string, errorMessage string) templ.Component {
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -31,7 +29,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>System Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>System Settings - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\" x-data=\"adminSettings\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -39,792 +37,338 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen bg-surface\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("gear", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = AdminSidebar(user, "/admin/settings").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-6 sm: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 btn-secondary font-medium\">")
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><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">System Settings</h1><p class=\"text-content-muted\">Configure your Bookhoard instance</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if errorMessage != "" { if errorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"mb-6 p-4 rounded-lg border border-line bg-surface-raised text-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, 5, "<span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 31, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 30, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6\"><h3 class=\"text-xl font-semibold mb-6 text-content\">Base URL</h3><div><label class=\"block text-sm font-medium mb-2 text-content\">Base URL</label> <input type=\"url\" name=\"base_url\" value=\"")
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, 8, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"]) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 45, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 41, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"https://books.example.com\" class=\"input\" required><p class=\"text-sm mt-1 text-content-muted\">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 font-medium\">Save Settings</button></div></div><div class=\"mt-8 card p-6\"><h3 class=\"text-xl font-semibold mb-6 text-content\">System Defaults</h3><div><label class=\"block text-sm font-medium mb-2 text-content\">Default Timezone</label> <select name=\"default_timezone\" id=\"default_timezone\" class=\"input\"><option value=\"UTC\"")
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, 10, "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, 11, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "UTC" { if systemConfig["default_timezone"] == "UTC" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Pacific/Honolulu" { if systemConfig["default_timezone"] == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "America/Anchorage" { if systemConfig["default_timezone"] == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "America/Los_Angeles" { if systemConfig["default_timezone"] == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "America/Denver" { if systemConfig["default_timezone"] == "America/Denver" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "America/Phoenix" { if systemConfig["default_timezone"] == "America/Phoenix" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "America/Chicago" { if systemConfig["default_timezone"] == "America/Chicago" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "America/New_York" { if systemConfig["default_timezone"] == "America/New_York" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "America/Sao_Paulo" { if systemConfig["default_timezone"] == "America/Sao_Paulo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Europe/London" { if systemConfig["default_timezone"] == "Europe/London" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Europe/Paris" { if systemConfig["default_timezone"] == "Europe/Paris" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Europe/Helsinki" { if systemConfig["default_timezone"] == "Europe/Helsinki" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Europe/Moscow" { if systemConfig["default_timezone"] == "Europe/Moscow" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Asia/Tehran" { if systemConfig["default_timezone"] == "Asia/Tehran" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Asia/Dubai" { if systemConfig["default_timezone"] == "Asia/Dubai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Asia/Karachi" { if systemConfig["default_timezone"] == "Asia/Karachi" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Asia/Kolkata" { if systemConfig["default_timezone"] == "Asia/Kolkata" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Asia/Dhaka" { if systemConfig["default_timezone"] == "Asia/Dhaka" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Asia/Bangkok" { if systemConfig["default_timezone"] == "Asia/Bangkok" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Asia/Shanghai" { if systemConfig["default_timezone"] == "Asia/Shanghai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Asia/Tokyo" { if systemConfig["default_timezone"] == "Asia/Tokyo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Australia/Darwin" { if systemConfig["default_timezone"] == "Australia/Darwin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Australia/Sydney" { if systemConfig["default_timezone"] == "Australia/Sydney" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if systemConfig["default_timezone"] == "Pacific/Auckland" { if systemConfig["default_timezone"] == "Pacific/Auckland" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">New Zealand (UTC+12/+13)</option></select><p class=\"text-sm mt-1 text-content-muted\">Default timezone for users who haven't set their own.</p></div></div></form><div class=\"mt-8 card p-6\"><h3 class=\"text-xl font-semibold mb-4 text-content\">URL Paths</h3><div class=\"space-y-2 text-sm text-content-muted\"><p><strong class=\"text-content\">OPDS:</strong> ")
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, 60, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 145} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 95, Col: 80}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "/opds</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">API:</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "/opds</p><p><strong class=\"text-content\">API:</strong> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 144} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 96, Col: 79}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "/api</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Device Sync:</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "/api</p><p><strong class=\"text-content\">Device Sync:</strong> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 108, Col: 152} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 97, Col: 87}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/api/sync</p></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "/api/sync</p></div></div></div></main></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = ScanSettingsSection(scanSettings).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = TunableSettingsSection(liveGroups, false).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = TunableSettingsSection(restartGroups, true).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func ScanSettingsSection(scanSettings ScanSettingsData) 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_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div id=\"scan-settings-section\" 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("refresh", "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, 66, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form hx-put=\"/admin/settings/scan\" hx-target=\"#scan-settings-section\" hx-swap=\"outerHTML\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if scanSettings.AutoScanEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " class=\"sr-only peer\"><div class=\"w-11 h-6 rounded-full peer peer-checked:bg-brand transition-colors\" style=\"background-color: color-mix(in srgb, var(--text-primary) 15%, transparent);\"></div><div class=\"absolute left-0.5 top-0.5 bg-white rounded-full w-5 h-5 transition-transform peer-checked:translate-x-5\"></div></label></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Scan Interval (seconds)</label> <input type=\"number\" name=\"scan_poll_interval_seconds\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 154, Col: 69}
}
_, 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, 69, "\" min=\"1\" max=\"3600\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">How often to poll libraries for changes (13600 seconds). Default: 60.</p></div><div class=\"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, 70, "Save Scan Settings</button></div></div></form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// TunableSettingsSection renders the editable tunables for a given bucket
// (live vs restart-required). Within the card, settings are clustered into
// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits").
func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if restartRequired {
templ_7745c5c3_Err = Icon("alert", "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, 72, " <h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Tunable Settings — Restart Required</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
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, 73, " <h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Tunable Settings — Live</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if restartRequired {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"text-sm mb-4\" style=\"color: var(--status-warning)\">Changes are saved immediately but only take effect after the server restarts.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Changes apply immediately — no restart needed.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, g := range groups {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"mt-5 first:mt-0\"><h4 class=\"text-xs font-semibold uppercase tracking-wide mb-1\" 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(g.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 194, Col: 112}
}
_, 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, 78, "</h4><div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, e := range g.Entries {
templ_7745c5c3_Err = TunableSettingRow(e).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// TunableSettingRow renders a single editable setting as an inline HTMX form.
func TunableSettingRow(e SettingEntry) 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_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<div class=\"flex flex-col sm:flex-row sm:items-center gap-3 py-3\" style=\"border-top: 1px solid color-mix(in srgb, var(--text-primary) 7%, transparent);\"><div class=\"flex-1 min-w-0\"><label class=\"block text-sm font-medium\" 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(e.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 209, Col: 94}
}
_, 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, 82, "</label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !e.IsDefault {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"text-xs mt-0.5\" 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(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 211, Col: 74}
}
_, 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, 84, " — modified from default</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 74}
}
_, 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, 86, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div><form class=\"flex items-center gap-2 shrink-0\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 219, Col: 33}
}
_, 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, 88, "\" hx-swap=\"innerHTML\" hx-disinherit=\"*\"><input type=\"hidden\" name=\"key\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 48}
}
_, 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, 89, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Type == "bool" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<select name=\"value\" class=\"input py-1.5 text-sm w-28\"><option value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Value == "true" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, ">Yes</option> <option value=\"false\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Value != "true" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, ">No</option></select> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if e.Type == "int" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<input type=\"number\" name=\"value\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 233, Col: 20}
}
_, 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, 96, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Min != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " min=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 235, Col: 17}
}
_, 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, 98, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if e.Max != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " max=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 17}
}
_, 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, 100, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " class=\"input py-1.5 text-sm w-32\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<input type=\"text\" name=\"value\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 246, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" class=\"input py-1.5 text-sm w-40\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("save", "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, 105, "Save</button></form><span id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 255, Col: 30}
}
_, 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, 106, "\" class=\"text-xs w-24 text-right\" style=\"color: var(--text-secondary)\"></span></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+30
View File
@@ -0,0 +1,30 @@
package templates
templ AdminSidebar(user User, currentPath string) {
<aside class="w-60 shrink-0 border-r border-line bg-surface-raised">
<div class="p-5">
<h2 class="mb-5 flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-content-muted">
@Icon("shield", "h-4 w-4")
Admin
</h2>
<nav class="space-y-1">
<a href="/admin" class={ activeClass(currentPath, "/admin") }>
@Icon("home", "h-4 w-4")
Dashboard
</a>
<a href="/admin/users" class={ activeClass(currentPath, "/admin/users") }>
@Icon("users", "h-4 w-4")
Users
</a>
<a href="/admin/library" class={ activeClass(currentPath, "/admin/library") }>
@Icon("library", "h-4 w-4")
Libraries
</a>
<a href="/admin/settings" class={ activeClass(currentPath, "/admin/settings") }>
@Icon("settings", "h-4 w-4")
Settings
</a>
</nav>
</div>
</aside>
}
+168
View File
@@ -0,0 +1,168 @@
// 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"
func AdminSidebar(user User, currentPath 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)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<aside class=\"w-60 shrink-0 border-r border-line bg-surface-raised\"><div class=\"p-5\"><h2 class=\"mb-5 flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-content-muted\">")
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, "Admin</h2><nav class=\"space-y-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 = []any{activeClass(currentPath, "/admin")}
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, 3, "<a href=\"/admin\" 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/admin_sidebar.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, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("home", "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, "Dashboard</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 = []any{activeClass(currentPath, "/admin/users")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/admin/users\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
}
_, 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, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("users", "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, "Users</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 = []any{activeClass(currentPath, "/admin/library")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"/admin/library\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
}
_, 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, 10, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("library", "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, "Libraries</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 = []any{activeClass(currentPath, "/admin/settings")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"/admin/settings\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
}
_, 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, 13, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("settings", "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, "Settings</a></nav></div></aside>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+14 -106
View File
@@ -8,7 +8,7 @@ package templates
import "github.com/a-h/templ" import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime" import templruntime "github.com/a-h/templ/runtime"
func Admin(user User, stats AdminStats) templ.Component { func Admin(user User) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -29,7 +29,7 @@ func Admin(user User, stats AdminStats) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus()\" class=\"theme-{ user.Theme }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Admin Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"admin\" x-init=\"loadWatchStatus(); initializeScanWebSocket()\" class=\"theme-tokyo-night\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -37,115 +37,31 @@ func Admin(user User, stats AdminStats) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen bg-surface\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("grid", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = AdminSidebar(user, "/admin").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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 instance</p></div><!-- Stats Grid --><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-6\"><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-6 sm:p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">Dashboard</h1><p class=\"text-content-muted\">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\"><div class=\"flex items-center space-x-3\"><div class=\"text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("library", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("library", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Libraries</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div><h3 class=\"font-semibold text-content\">Library</h3><p class=\"text-content-muted text-sm\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"btn btn-secondary mt-4 text-sm\">View Library</a></div><div class=\"card p-6\"><div class=\"flex items-center space-x-3\"><div class=\"text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string templ_7745c5c3_Err = Icon("sync", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div><h3 class=\"font-semibold text-content\">Scan Watch Status</h3><p class=\"text-content-muted text-sm\">Auto-detecting new files</p></div></div><div id=\"watch-status\" class=\"mt-4 text-sm text-content-muted\"><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\"><h3 class=\"text-xl font-semibold mb-4 text-content\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn btn-primary p-4 text-left\"><div class=\"font-medium\">Rescan Library</div><div class=\"text-content-muted text-sm\">Re-scan existing files and fix metadata</div></button> <a href=\"/admin/library\" class=\"btn btn-secondary p-4 text-left block\"><div class=\"font-medium\">Manage Libraries and Folders</div><div class=\"text-content-muted text-sm\">Add or remove libraries and scan directories</div></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"card mt-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 text-content flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "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, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Books</span></div><p class=\"text-2xl 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.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90}
}
_, 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><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("users", "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 class=\"text-xs font-semibold uppercase tracking-wide\">Users</span></div><p class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89}
}
_, 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><div class=\"stat-card\"><div class=\"flex items-center gap-2 mb-2\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("device", "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, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Devices</span></div><p class=\"text-2xl font-bold\" 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(stats.DeviceCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91}
}
_, 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, "</p></div></div><!-- Watch Status --><div class=\"stat-card mb-6\"><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, 12, "</span><div class=\"flex-1\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">File Watcher</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Auto-detects new files in library folders</p></div><div class=\"text-right 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><!-- Quick Actions --><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, 13, "Scan All Libraries</span> <span class=\"text-xs font-normal opacity-80\">Re-scan existing files and detect new items</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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -153,31 +69,23 @@ func Admin(user User, stats AdminStats) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Manage Libraries</span> <span class=\"text-xs font-normal opacity-80\">Add or remove libraries and folders</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)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"p-2 hover:bg-surface-hover rounded\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("refresh", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</button></div><!-- Overall Progress --><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span class=\"text-content-muted\">Overall Progress</span> <span id=\"scan-progress-text\" class=\"text-content\">0%</span></div><div class=\"w-full bg-surface-hover rounded-full h-3\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500 bg-brand\" style=\"width: 0%;\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2 text-content-muted\">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 border-line bg-surface\"><h4 class=\"font-semibold mb-2 text-content flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("check-circle", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div><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><div id=\"library-progress-list\" class=\"space-y-3\"></div><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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Scan Complete!</h4><div id=\"scan-results-content\" class=\"text-content-muted\"><!-- 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
}
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, 17, "Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"></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></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+30 -39
View File
@@ -8,55 +8,47 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
<title>Users - Bookhoard Admin</title> <title>Users - Bookhoard Admin</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ currentUser.Theme }"> <body class="theme-{ currentUser.Theme }">
@Header(currentUser, "/admin/users") @Header(currentUser, "/admin/users")
<!-- Modal Container (populated by HTMX) --> <!-- Modal Container (populated by HTMX) -->
<div id="modal-container"></div> <div id="modal-container"></div>
<main class="p-8"> <div class="flex min-h-screen bg-surface">
<div class="max-w-5xl"> @AdminSidebar(currentUser, "/admin/users")
<div class="mb-8"> <main class="flex-1 p-6 sm:p-8">
<div class="flex items-center gap-3 mb-1"> <div class="w-full">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);"> <h1 class="text-2xl font-bold tracking-tight text-content mb-2">User Management</h1>
@Icon("users", "h-5 w-5") <p class="text-content-muted">Manage user accounts and permissions</p>
</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> </div>
<!-- Users Table --> <!-- Users Table -->
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<table class="w-full"> <table class="w-full">
<thead style="background-color: var(--bg-primary)"> <thead class="bg-surface">
<tr> <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 text-content-muted">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 text-content-muted">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 text-content-muted">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-left text-xs font-semibold uppercase tracking-wide text-content-muted">Created</th>
<th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Actions</th> <th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide text-content-muted">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y" style="divide-color: var(--border)"> <tbody class="divide-y divide-line">
for _, user := range users { for _, user := range users {
<tr id={ "user-" + user.ID } class="transition-colors hover:bg-surface-hover"> <tr id={ "user-" + user.ID } class="hover:bg-surface-hover" style="transition: background-color 0.2s;">
<!-- Username --> <!-- Username -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2"> <div class="flex items-center">
<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>
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div> <div class="text-sm font-medium text-content">{ user.Username }</div>
if user.ID == currentUser.ID { if user.ID == currentUser.ID {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span> <span class="badge bg-brand text-white">You</span>
} }
</div> </div>
</div> </div>
</td> </td>
<!-- Email --> <!-- Email -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div> <div class="text-sm text-content">{ user.Email }</div>
</td> </td>
<!-- Role Toggle (with last-admin protection) --> <!-- Role Toggle (with last-admin protection) -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
@@ -65,7 +57,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
<div class="relative"> <div class="relative">
<select <select
disabled disabled
class="input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed" class="input text-sm cursor-not-allowed opacity-50"
title="Cannot demote the last admin" title="Cannot demote the last admin"
> >
<option value="user">User</option> <option value="user">User</option>
@@ -75,11 +67,14 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
} else { } else {
<select <select
hx-put={ "/api/auth/profile/" + user.ID } hx-put={ "/api/auth/profile/" + user.ID }
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target={ "#role-result-" + user.ID } hx-target={ "#role-result-" + user.ID }
hx-swap="innerHTML" hx-swap="innerHTML"
hx-trigger="change"
name="role" name="role"
class="input w-auto py-1 pr-7 text-xs" class="input text-sm"
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="user" selected?={ user.Role == "user" }>User</option>
<option value="admin" selected?={ user.Role == "admin" }>Admin</option> <option value="admin" selected?={ user.Role == "admin" }>Admin</option>
@@ -89,50 +84,46 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
</td> </td>
<!-- Created --> <!-- Created -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div> <div class="text-sm text-content-muted">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
</td> </td>
<!-- Actions --> <!-- Actions -->
<td class="px-6 py-4 whitespace-nowrap text-right"> <td class="px-6 py-4 whitespace-nowrap text-right">
<div class="inline-flex items-center gap-1">
<button <button
hx-get={ "/admin/users/" + user.ID + "/profile-modal" } hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
hx-target="#modal-container" hx-target="#modal-container"
hx-swap="innerHTML" hx-swap="innerHTML"
class="btn btn-secondary text-xs px-2.5 py-1" class="btn btn-primary text-sm mr-2"
> >
@Icon("edit", "h-4 w-4")
Edit Edit
</button> </button>
if user.Role == "admin" && adminCount == 1 { if user.Role == "admin" && adminCount == 1 {
<button <button
disabled disabled
class="btn btn-danger text-xs px-2.5 py-1" class="btn btn-danger text-sm cursor-not-allowed opacity-50"
title="Cannot delete the last admin" title="Cannot delete the last admin"
> >
@Icon("trash", "h-4 w-4")
Delete Delete
</button> </button>
} else { } else {
<button <button
hx-delete={ "/api/auth/profile/" + user.ID } hx-delete={ "/api/auth/profile/" + user.ID }
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
hx-target={ "#user-" + user.ID } hx-target={ "#user-" + user.ID }
hx-swap="outerHTML swap:0.5s" hx-swap="outerHTML swap:0.5s"
hx-confirm="Are you sure you want to delete this user? This action cannot be undone." 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" class="btn btn-danger text-sm"
> >
@Icon("trash", "h-4 w-4")
Delete Delete
</button> </button>
} }
</div>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</main> </main>
</div>
</body> </body>
</html> </html>
} }
+37 -69
View File
@@ -29,7 +29,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Users - Bookhoard Admin</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ currentUser.Theme }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Users - Bookhoard Admin</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ currentUser.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -37,15 +37,15 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><main class=\"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);\">") 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 bg-surface\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("users", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = AdminSidebar(currentUser, "/admin/users").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-6 sm:p-8\"><div class=\"w-full\"><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">User Management</h1><p class=\"text-content-muted\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card overflow-hidden\"><table class=\"w-full\"><thead class=\"bg-surface\"><tr><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted\">Username</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted\">Email</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted\">Role</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide text-content-muted\">Created</th><th class=\"px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide text-content-muted\">Actions</th></tr></thead> <tbody class=\"divide-y divide-line\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -57,227 +57,195 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" 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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"hover:bg-surface-hover\" 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 text-content\">")
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, 6, "</span><div><div class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 73}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.ID == currentUser.ID { if user.ID == currentUser.ID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"badge bg-brand text-white\">You</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</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, 8, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 51, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">") 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\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" && adminCount == 1 { if user.Role == "admin" && adminCount == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<!-- 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!-- Last admin - disabled --> <div class=\"relative\"><select disabled class=\"input text-sm cursor-not-allowed opacity-50\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<select hx-put=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<select hx-put=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 69, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 71, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" hx-swap=\"innerHTML\" hx-trigger=\"change\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\"><option value=\"user\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-swap=\"innerHTML\" name=\"role\" class=\"input text-sm\" onchange=\"this.dispatchEvent(new Event('htmx:trigger'))\" hx-trigger=\"change\" hx-vals='{\"role\": this.value}'><option value=\"user\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "user" { if user.Role == "user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">User</option> <option value=\"admin\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" { if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">Admin</option></select><div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Admin</option></select><div id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 82, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"text-xs mt-1\"></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"text-xs mt-1\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</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, 19, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 106}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div></td><!-- Actions --><td class=\"px-6 py-4 whitespace-nowrap text-right\"><div class=\"inline-flex items-center gap-1\"><button hx-get=\"") 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=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal") templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-xs px-2.5 py-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary text-sm mr-2\">Edit</button> ")
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, 23, "Edit</button> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" && adminCount == 1 { if user.Role == "admin" && adminCount == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button disabled class=\"btn btn-danger text-xs px-2.5 py-1\" title=\"Cannot delete the last admin\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button disabled class=\"btn btn-danger text-sm cursor-not-allowed opacity-50\" title=\"Cannot delete the last admin\">Delete</button>")
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, 25, "Delete</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button hx-delete=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button hx-delete=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 109, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-target=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 111, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" 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\">") 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=\"btn btn-danger text-sm\">Delete</button>")
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, "Delete</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</tbody></table></div></div></main></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div></main></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+33 -30
View File
@@ -9,24 +9,22 @@ templ Analytics(user User) {
<title>Reading Analytics - Bookhoard</title> <title>Reading Analytics - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }"> <body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
@Header(user, "/analytics") @Header(user, "/analytics")
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center gap-3 mb-1"> <h1 class="mb-2 flex items-center gap-2 text-2xl font-bold tracking-tight text-content">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);"> @Icon("chart", "h-6 w-6")
@Icon("chart", "h-5 w-5") Reading Analytics
</span> </h1>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Analytics</h1> <p class="text-content-muted">Track your reading habits and device usage</p>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Track your reading habits and device usage</p>
</div> </div>
<!-- Date Range Picker -->
<div class="card p-4 mb-6"> <div class="card p-4 mb-6">
<div class="flex flex-wrap gap-4 items-end"> <div class="flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]"> <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> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Start Date</label>
<input <input
type="date" type="date"
id="start-date" id="start-date"
@@ -35,7 +33,7 @@ templ Analytics(user User) {
/> />
</div> </div>
<div class="flex-1 min-w-[200px]"> <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> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">End Date</label>
<input <input
type="date" type="date"
id="end-date" id="end-date"
@@ -43,47 +41,52 @@ templ Analytics(user User) {
class="input" class="input"
/> />
</div> </div>
<div> <div class="flex items-end">
<button @click="loadAnalytics()" class="btn btn-primary">Update</button> <button @click="loadAnalytics()" class="btn btn-primary">Update</button>
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8" id="stats-container"> <!-- Stats Cards -->
<div class="stat-card"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Books Read</h3> <div class="card p-5">
<p id="total-books" class="text-3xl font-bold" style="color: var(--accent);">-</p> <h3 class="text-xs font-semibold uppercase tracking-wide text-content-muted">Books Read</h3>
<p id="total-books" class="mt-1 text-2xl font-bold text-content">-</p>
</div> </div>
<div class="stat-card"> <div class="card p-5">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Pages Read</h3> <h3 class="text-xs font-semibold uppercase tracking-wide text-content-muted">Pages Read</h3>
<p id="total-pages" class="text-3xl font-bold" style="color: var(--accent);">-</p> <p id="total-pages" class="mt-1 text-2xl font-bold text-content">-</p>
</div> </div>
<div class="stat-card"> <div class="card p-5">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Reading Time</h3> <h3 class="text-xs font-semibold uppercase tracking-wide text-content-muted">Reading Time</h3>
<p id="reading-time" class="text-3xl font-bold" style="color: var(--accent);">-</p> <p id="reading-time" class="mt-1 text-2xl font-bold text-content">-</p>
</div> </div>
<div class="stat-card"> <div class="card p-5">
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Completion Rate</h3> <h3 class="text-xs font-semibold uppercase tracking-wide text-content-muted">Completion Rate</h3>
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--accent);">-</p> <p id="completion-rate" class="mt-1 text-2xl font-bold text-content">-</p>
</div> </div>
</div> </div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Daily Reading Chart -->
<div class="card p-6"> <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> <h3 class="text-lg font-semibold mb-4 text-content">Daily Reading Minutes</h3>
<div class="h-80"> <div class="h-80">
<canvas id="daily-reading-chart"></canvas> <canvas id="daily-reading-chart"></canvas>
</div> </div>
</div> </div>
<!-- Device Usage Chart -->
<div class="card p-6"> <div class="card p-6">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Device Usage</h3> <h3 class="text-lg font-semibold mb-4 text-content">Device Usage</h3>
<div class="h-80"> <div class="h-80">
<canvas id="device-usage-chart"></canvas> <canvas id="device-usage-chart"></canvas>
</div> </div>
</div> </div>
</div> </div>
<!-- Popular Books -->
<div class="card p-6"> <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> <h3 class="text-lg font-semibold mb-4 text-content">Most Read Books</h3>
<div id="popular-books" class="space-y-3"> <div id="popular-books" class="space-y-3">
<div class="text-center py-8" style="color: var(--text-secondary);"> <div class="text-center py-8 text-content-muted">
<div class="loading-spinner mx-auto mb-4"></div> <div class="loading-spinner mx-auto mb-4"></div>
<p>Loading analytics...</p> <p>Loading analytics...</p>
</div> </div>
+4 -4
View File
@@ -29,7 +29,7 @@ func Analytics(user User) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Reading Analytics - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"analytics\" x-init=\"loadAnalytics\" class=\"theme-{ user.Theme }\">") 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>Reading Analytics - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"analytics\" x-init=\"loadAnalytics\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -37,15 +37,15 @@ func Analytics(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">") 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=\"mb-2 flex items-center gap-2 text-2xl font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("chart", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("chart", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Reading Analytics</h1><p class=\"text-content-muted\">Track your reading habits and device usage</p></div><!-- Date Range Picker --><div class=\"card p-4 mb-6\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex items-end\"><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-5\"><h3 class=\"text-xs font-semibold uppercase tracking-wide text-content-muted\">Books Read</h3><p id=\"total-books\" class=\"mt-1 text-2xl font-bold text-content\">-</p></div><div class=\"card p-5\"><h3 class=\"text-xs font-semibold uppercase tracking-wide text-content-muted\">Pages Read</h3><p id=\"total-pages\" class=\"mt-1 text-2xl font-bold text-content\">-</p></div><div class=\"card p-5\"><h3 class=\"text-xs font-semibold uppercase tracking-wide text-content-muted\">Reading Time</h3><p id=\"reading-time\" class=\"mt-1 text-2xl font-bold text-content\">-</p></div><div class=\"card p-5\"><h3 class=\"text-xs font-semibold uppercase tracking-wide text-content-muted\">Completion Rate</h3><p id=\"completion-rate\" class=\"mt-1 text-2xl font-bold text-content\">-</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\"><h3 class=\"text-lg font-semibold mb-4 text-content\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><!-- Device Usage Chart --><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4 text-content\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><!-- Popular Books --><div class=\"card p-6\"><h3 class=\"text-lg font-semibold mb-4 text-content\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8 text-content-muted\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+46 -79
View File
@@ -2,97 +2,64 @@ package templates
templ APIExplorer(explorer APIExplorerData) { templ APIExplorer(explorer APIExplorerData) {
if !explorer.IsLoggedIn { if !explorer.IsLoggedIn {
<div x-data="devices" class="card p-6 mt-8 space-y-5"> // Show mode toggle and mock data
<div class="flex items-center gap-2 flex-wrap"> <div x-data="devices" class="card p-6 mt-8">
if explorer.Endpoint.Method == "GET" { <div class="flex gap-2 mb-4">
<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="flex gap-2">
<button class="btn btn-primary">Mock Data</button> <button class="btn btn-primary">Mock Data</button>
<button class="btn btn-secondary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button> <button class="btn btn-secondary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button>
</div> </div>
<div> <div class="mb-4">
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body (Example)</p> <div class="mb-4">
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.RequestBody }</code></pre> <select disabled class="input w-full">
</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);">{ 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 {
<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="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="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option> <option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option> <option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option> <option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
</select> </select>
</div> </div>
<div> <h4 class="text-content-muted mb-2 text-sm font-medium">Request Body (Example)</h4>
<label for="request-body" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body</label> <pre class="bg-surface p-4 rounded-lg overflow-x-auto"><code class="text-content text-sm">{ explorer.Endpoint.RequestBody }</code></pre>
<textarea id="request-body" placeholder="Edit request body…" class="input min-h-[150px] font-mono">{ explorer.Endpoint.RequestBody }</textarea>
</div> </div>
<button id="try-it-out" @click="tryDocEndpoint()" class="btn btn-primary"> <div class="mt-4">
@Icon("play", "h-4 w-4") <h4 class="text-content-muted mb-2 text-sm font-medium">Response (Mock)</h4>
Try It Out <pre class="bg-surface p-4 rounded-lg overflow-x-auto"><code class="text-content text-sm">{ explorer.Endpoint.Response }</code></pre>
</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> </div>
<pre class="card p-4 overflow-x-auto"><code id="response-body" class="text-sm" style="color: var(--text-primary);"></code></pre> <div class="flex gap-2 mt-4">
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="btn btn-secondary text-sm">Copy Request</button>
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="btn btn-secondary text-sm">Copy Response</button>
</div> </div>
<div class="flex gap-2 flex-wrap"> </div>
<button @click="copyDocRequest()" class="btn btn-secondary text-sm"> } else {
@Icon("copy", "h-4 w-4") // Show interactive explorer with real execution
Copy Request <div class="card p-6 mt-8" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
</button> <div class="flex gap-2 mb-4">
<button @click="copyDocResponse()" class="btn btn-secondary text-sm"> <button class="mode-btn btn btn-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
@Icon("copy", "h-4 w-4") <button class="mode-btn btn btn-primary text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
Copy Response </div>
</button> <div class="mb-4">
<button @click="generateDocCURL()" class="btn btn-secondary text-sm"> <div class="mb-4">
@Icon("external", "h-4 w-4") <select id="http-method" class="input w-full">
Generate cURL <option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
</button> <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-content-muted mb-2 text-sm font-medium">Request Body</h4>
<textarea id="request-body" placeholder="Edit request body..." class="input w-full min-h-[150px] font-mono text-sm">{ explorer.Endpoint.RequestBody }</textarea>
</div>
<button id="try-it-out" @click="tryDocEndpoint()" class="btn btn-primary mt-4">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-content-muted"></span>
</div>
<pre class="bg-surface p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-content text-sm"></code></pre>
</div>
<div class="flex gap-2 mt-4 flex-wrap">
<button @click="copyDocRequest()" class="btn btn-secondary text-sm">Copy Request</button>
<button @click="copyDocResponse()" class="btn btn-secondary text-sm">Copy Response</button>
<button @click="generateDocCURL()" class="btn btn-secondary text-sm">Generate cURL</button>
</div> </div>
</div> </div>
} }
+88 -276
View File
@@ -30,319 +30,131 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
if !explorer.IsLoggedIn { if !explorer.IsLoggedIn {
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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " <div x-data=\"devices\" class=\"card p-6 mt-8\"><div class=\"flex gap-2 mb-4\"><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=\"mb-4\"><div class=\"mb-4\"><select disabled class=\"input w-full\"><option value=\"GET\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if explorer.Endpoint.Method == "GET" { if explorer.Endpoint.Method == "GET" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"badge status-completed\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">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, 4, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">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, 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\"")
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
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">DELETE</option></select></div><h4 class=\"text-content-muted mb-2 text-sm font-medium\">Request Body (Example)</h4><pre class=\"bg-surface p-4 rounded-lg overflow-x-auto\"><code class=\"text-content text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 21, Col: 125}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</code></pre></div><div class=\"mt-4\"><h4 class=\"text-content-muted mb-2 text-sm font-medium\">Response (Mock)</h4><pre class=\"bg-surface p-4 rounded-lg overflow-x-auto\"><code class=\"text-content text-sm\">")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 25, Col: 122}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ") 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=\"btn btn-secondary text-sm\">Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"btn btn-secondary text-sm\">Copy Response</button></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else if explorer.Endpoint.Method == "POST" { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " <div class=\"card p-6 mt-8\" 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 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 class=\"mb-4\"><div class=\"mb-4\"><select id=\"http-method\" class=\"input w-full\"><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, 13, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">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")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">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")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">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")
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-content-muted mb-2 text-sm font-medium\">Request Body</h4><textarea id=\"request-body\" placeholder=\"Edit request body...\" class=\"input w-full min-h-[150px] font-mono text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 151}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"btn btn-primary mt-4\">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-content-muted\"></span></div><pre class=\"bg-surface p-4 rounded-lg overflow-x-auto\"><code id=\"response-body\" class=\"text-content text-sm\"></code></pre></div><div class=\"flex gap-2 mt-4 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"btn btn-secondary text-sm\">Copy Request</button> <button @click=\"copyDocResponse()\" class=\"btn btn-secondary text-sm\">Copy Response</button> <button @click=\"generateDocCURL()\" class=\"btn btn-secondary text-sm\">Generate cURL</button></div></div>")
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, 10, "<code class=\"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(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 = 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, "</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
}
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 = 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, 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
}
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
}
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 = 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, 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, 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, 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, 29, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
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, 31, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
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, 33, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
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_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: 70, Col: 136}
}
_, 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, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+146 -228
View File
@@ -4,9 +4,9 @@ import (
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"fmt" "fmt"
"net/url" "net/url"
"strings"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"strings"
) )
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
@@ -18,7 +18,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<title>{ book.Title } - Bookhoard</title> <title>{ book.Title } - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body <body
x-data="bookDetail" x-data="bookDetail"
@@ -30,400 +29,319 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
> >
@Header(user, "/media/{ uuidToString(book.ID) }") @Header(user, "/media/{ uuidToString(book.ID) }")
<!-- Hero with blurred cover backdrop --> <!-- Back bar -->
<div class="relative overflow-hidden"> <div class="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { <div class="w-full px-4 sm:px-6 lg:px-8 py-2.5">
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div> <a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
}
<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") @Icon("arrow-left", "h-4 w-4")
<span>Back</span> Back
</a> </a>
<div class="flex flex-col md:flex-row gap-8"> </div>
<!-- Cover --> </div>
<div class="flex-shrink-0 mx-auto md:mx-0">
<div class="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-8">
<!-- Cover + header -->
<div class="flex flex-col gap-8 md:flex-row">
<div class="flex-shrink-0">
<div class="overflow-hidden rounded-xl shadow-card-hover">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<img <img
src={ book.CoverImagePath.String } src={ book.CoverImagePath.String }
alt={ book.Title } alt={ book.Title }
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl" class="w-48 object-cover md:w-56 lg:w-60"
style="box-shadow: var(--shadow-pop);"
onerror="this.src='/static/placeholder-book.svg'" onerror="this.src='/static/placeholder-book.svg'"
/> />
} else { } else {
<img <img src="/static/placeholder-book.svg" alt={ book.Title } class="w-48 object-cover md:w-56 lg:w-60"/>
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);"
/>
} }
</div> </div>
<!-- Details --> </div>
<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> <div class="min-w-0 flex-1">
<h1 class="text-3xl font-bold tracking-tight text-content">{ book.Title }</h1>
if book.Author.Valid && book.Author.String != "" { if book.Author.Valid && book.Author.String != "" {
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)"> <p class="mt-1 text-lg text-content-muted">by { book.Author.String }</p>
by { book.Author.String }
</h2>
} }
<!-- Action buttons -->
<div class="flex flex-wrap gap-2.5 mb-6"> <!-- Actions -->
<button <div class="mt-5 flex flex-wrap gap-2">
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" } <button @click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" } class="btn btn-primary">
class="btn btn-primary px-5 py-2.5"
>
@Icon("book-open", "h-4 w-4") @Icon("book-open", "h-4 w-4")
<span>Read Now</span> Read Now
</button> </button>
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 { 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"> <button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4")<span>Mark Unread</span></span> <span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4") Mark as Unread</span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none"> <svg x-show="readSaving" x-cloak class="inline-block h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
</button> </button>
} else { } else {
<button @click="toggleRead(true)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5"> <button @click="toggleRead(true)" :disabled="readSaving" class="btn btn-secondary">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check", "h-4 w-4")<span>Mark Read</span></span> <span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("book", "h-4 w-4") Mark as Read</span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none"> <svg x-show="readSaving" x-cloak class="inline-block h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
</button> </button>
} }
if book.ActiveConflict != nil || book.ReadingProgress != nil { if book.ActiveConflict != nil || book.ReadingProgress != nil {
<button @click="showProgressSyncModal()" class="btn btn-secondary px-5 py-2.5"> <button @click="showProgressSyncModal()" class="btn btn-secondary">
@Icon("sync", "h-4 w-4") @Icon("sync", "h-4 w-4")
<span>Sync Progress</span> Sync Progress
</button> </button>
} }
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5"> <button @click="showNotesModal()" class="btn btn-secondary">
@Icon("edit", "h-4 w-4") @Icon("edit", "h-4 w-4")
<span>Notes</span> Notes
if book.NotesCount + book.HighlightsCount > 0 { 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> <span class="badge bg-brand/20 text-brand">{ book.NotesCount + book.HighlightsCount }</span>
} }
</button> </button>
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5"> <button @click="showMetadataEditor()" class="btn btn-ghost">
@Icon("edit", "h-4 w-4") @Icon("settings", "h-4 w-4")
<span>Edit</span> Edit
</button> </button>
</div> </div>
<!-- Rating --> <!-- Rating -->
<div class="mb-5" @mouseleave="ratingHover = 0"> <div class="mt-5 flex flex-wrap items-center gap-2" @mouseleave="ratingHover = 0">
<span class="text-2xl"> <span class="text-2xl tracking-tight">
<template x-for="i in 5" :key="i"> <template x-for="i in 5" :key="i">
<span style="position: relative; display: inline-block;"> <span style="position: relative; display: inline-block;">
<span :style="starFill(i)"></span> <span :style="starFill(i)" class="text-content-muted/40"></span>
<button <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>
type="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>
@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> </span>
</template> </template>
</span> </span>
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span> <span class="text-sm text-content-muted" x-text="ratingText()"></span>
<button <button type="button" x-show="userRating > 0 && !ratingSaving" x-cloak @click="clearRating()" class="text-xs text-content-muted underline hover:text-content">Clear</button>
type="button" <svg x-show="ratingSaving" x-cloak class="inline-block h-4 w-4 animate-spin text-content-muted" viewBox="0 0 24 24" fill="none">
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> <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> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
</div> </div>
<!-- Community rating --> <!-- Community rating -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 { if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
<div class="mb-3 text-sm" style="color: var(--text-secondary);"> <div class="mt-2 flex items-center gap-2 text-sm text-content-muted">
Community: <span>Community:</span>
<span class="font-bold ml-1" style="color: var(--text-primary);"> <span class="text-base">@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))</span>
@templ.Raw(renderStars(int32(book.CommunityRating.Float64))) <span>{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
</span>
<span class="ml-2">{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
</div> </div>
} }
<!-- Badges: series, direction, tags -->
<div class="flex flex-wrap items-center gap-2 mb-3"> <!-- Series badge -->
if book.Series.Valid && book.Series.String != "" { if book.Series.Valid && book.Series.String != "" {
<div class="mt-4">
<a <a
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) } href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
class="chip hover:opacity-90" class="chip hover:opacity-90"
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
> >
@Icon("layers", "h-3.5 w-3.5")
{ book.Series.String } { book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 { if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 } #{ book.SeriesNumber.Int32 }
} }
</a> </a>
</div>
} }
<!-- Reading direction + comic badges -->
<div class="mt-4 flex flex-wrap items-center gap-2">
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" { 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> <span class="chip">@Icon("book-open", "h-3.5 w-3.5"){ strings.ToUpper(book.ReadingDirection.String) }</span>
} }
if book.AgeRating.Valid && book.AgeRating.String != "" { if book.AgeRating.Valid && book.AgeRating.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span> <span class="badge bg-surface-hover text-content-muted">{ book.AgeRating.String }</span>
} }
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool { if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span> <span class="badge bg-surface-hover text-content-muted">B&amp;W</span>
} }
if book.StoryArc.Valid && book.StoryArc.String != "" { if book.StoryArc.Valid && book.StoryArc.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.StoryArc.String }</span> <span class="badge bg-surface-hover text-content-muted">{ book.StoryArc.String }</span>
} }
</div> </div>
<!-- Tags -->
if len(book.Tags) > 0 { if len(book.Tags) > 0 {
<div class="flex flex-wrap gap-2 mb-4"> <div class="mt-4 flex flex-wrap gap-1.5">
for _, tag := range book.Tags { for _, tag := range book.Tags {
<a <a href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) } class="badge bg-surface-hover text-content-muted transition-colors hover:bg-surface-hover hover:text-content">
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 } { tag }
</a> </a>
} }
</div> </div>
} }
</div>
</div>
</div>
</div>
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
<!-- Synopsis --> <!-- Synopsis -->
if book.Description.Valid && book.Description.String != "" { if book.Description.Valid && book.Description.String != "" {
<div class="card p-6 mb-6"> <div class="mt-5">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3> <h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">Synopsis</h3>
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)"> <div class="max-h-80 overflow-y-auto pr-2 text-sm leading-relaxed text-content-muted">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent() @UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
</div> </div>
</div> </div>
} }
</div>
</div>
<div class="mt-8 space-y-6">
<!-- Summary -->
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String { if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
<div class="card p-6 mb-6"> <div>
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3> <h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-content-muted">Comic Summary</h3>
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)"> <div class="max-h-80 overflow-y-auto pr-2 text-sm leading-relaxed text-content-muted">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent() @UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
</div> </div>
</div> </div>
} }
<!-- Metadata notes -->
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" { if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
<div class="card p-6 mb-6"> <div class="card p-5">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3> <h3 class="mb-2 text-sm font-semibold text-content">Metadata Notes</h3>
<div class="text-sm" style="color: var(--text-secondary);">{ book.MetadataNotes.String }</div> <p class="text-sm text-content-muted">{ book.MetadataNotes.String }</p>
</div> </div>
} }
<!-- Progress --> <!-- Progress -->
if book.ReadingProgress != nil { if book.ReadingProgress != nil {
<div class="card p-6 mb-6"> <div class="card p-5">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3> <h3 class="mb-4 text-sm font-semibold text-content">Reading Progress</h3>
if book.ActiveConflict != nil { if book.ActiveConflict != nil {
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border: 1px solid var(--status-warning);"> <div class="mb-4 flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm text-content">
@Icon("alert", "h-5 w-5 shrink-0") @Icon("alert", "h-4 w-4 mt-0.5 shrink-0 text-warning")
<p class="text-sm" style="color: var(--text-primary);">Progress conflict detected click "Sync Progress" to review and resolve.</p> <span>Progress conflict detected click "Sync Progress" to review and resolve.</span>
</div> </div>
} }
<div class="mb-4"> <div class="mb-4">
<div class="w-full rounded-full h-2.5" style="background-color: var(--bg-primary);"> <div class="h-2 w-full overflow-hidden rounded-full bg-surface-hover">
<div <div class="h-2 rounded-full bg-brand transition-all" style={ fmt.Sprintf("width: %.1f%%", book.ReadingProgress.Percentage.Float64*100) }></div>
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>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div> <div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Progress</p> <p class="text-xs uppercase tracking-wide text-content-muted">Progress</p>
<p class="text-2xl font-bold" style="color: var(--accent);">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p> <p class="text-xl font-bold text-brand">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
</div> </div>
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid { if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
<div> <div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Page</p> <p class="text-xs uppercase tracking-wide text-content-muted">Page</p>
<p style="color: var(--text-primary)">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p> <p class="text-content">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
</div> </div>
} }
if book.ReadingProgress.LastReadAt.Valid { if book.ReadingProgress.LastReadAt.Valid {
<div> <div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Last Read</p> <p class="text-xs uppercase tracking-wide text-content-muted">Last Read</p>
<p class="text-sm" style="color: var(--text-primary)">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p> <p class="text-content">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
</div> </div>
} }
if book.ReadingProgress.LastSyncSource.Valid { if book.ReadingProgress.LastSyncSource.Valid {
<div> <div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Source</p> <p class="text-xs uppercase tracking-wide text-content-muted">Source</p>
<p class="capitalize" style="color: var(--text-primary)">{ book.ReadingProgress.LastSyncSource.String }</p> <p class="capitalize text-content">{ book.ReadingProgress.LastSyncSource.String }</p>
</div> </div>
} }
</div> </div>
</div> </div>
} }
<!-- Metadata grid --> <!-- Metadata grid -->
<div class="card p-6 mb-6"> <div class="card p-5">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3> <h3 class="mb-4 text-sm font-semibold text-content">Metadata</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4 text-sm"> <dl class="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 lg:grid-cols-3">
if book.Publisher.Valid && book.Publisher.String != "" { if book.Publisher.Valid && book.Publisher.String != "" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Publisher</dt><dd class="text-content">{ book.Publisher.String }</dd></div>
<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 { if book.DatePublished.Valid {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Published</dt><dd class="text-content">{ book.DatePublished.Time.Format("01-02-2006") }</dd></div>
<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 != "" { if book.Isbn.Valid && book.Isbn.String != "" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">ISBN</dt><dd class="text-content">{ book.Isbn.String }</dd></div>
<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 != "" { if book.Language.Valid && book.Language.String != "" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Language</dt><dd class="capitalize text-content">{ book.Language.String }</dd></div>
<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 != "" { if book.Edition.Valid && book.Edition.String != "" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Edition</dt><dd class="text-content">{ book.Edition.String }</dd></div>
<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 { if book.PageCount.Valid && book.PageCount.Int32 > 0 {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Pages</dt><dd class="text-content">{ book.PageCount.Int32 }</dd></div>
<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 != "" { if book.Genre.Valid && book.Genre.String != "" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Genre</dt><dd class="text-content">{ book.Genre.String }</dd></div>
<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>
} }
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 { if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Series Count</dt><dd class="text-content">{ book.SeriesCount.Int32 } items</dd></div>
<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 { if book.Volume.Valid && book.Volume.Int32 > 0 {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Volume</dt><dd class="text-content">Vol. { book.Volume.Int32 }</dd></div>
<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 != "" { if book.Imprint.Valid && book.Imprint.String != "" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Imprint</dt><dd class="text-content">{ book.Imprint.String }</dd></div>
<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 { if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Copyright Year</dt><dd class="text-content">{ book.CopyrightYear.Int32 }</dd></div>
<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>
} }
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Manga Type</dt><dd class="capitalize text-content">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</dd></div>
<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 != "" { if book.ScanInformation.Valid && book.ScanInformation.String != "" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Scan Info</dt><dd class="text-sm text-content-muted">{ book.ScanInformation.String }</dd></div>
<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 altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" { if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Alternate Series</dt><dd class="text-content">{ altSeries }</dd></div>
<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 { if len(book.Contributors) > 0 {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Contributors</dt><dd class="text-content">{ strings.Join(book.Contributors, ", ") }</dd></div>
<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>
} }
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">Format</dt><dd class="text-content">{ book.MimeType.String }</dd></div>
<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 { if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div> <div><dt class="text-xs uppercase tracking-wide text-content-muted">File Size</dt><dd class="text-content">{ formatFileSize(book.FileSize.Int64) }</dd></div>
<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> </dl>
<!-- External links -->
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid { if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<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);"> <div class="mt-5 border-t border-line pt-4">
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" { <h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">External Links</h4>
<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> <div class="flex flex-wrap gap-2">
} else { <a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("book", "h-3 w-3") Goodreads</a>
<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> <a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("book-open", "h-3 w-3") Open Library</a>
} <a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("search", "h-3 w-3") Google Books</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 != "" { 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> <a href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") Amazon</a>
} else if book.Isbn.Valid && book.Isbn.String != "" { } 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> <a href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") Amazon</a>
} }
if book.WebUrl.Valid && book.WebUrl.String != "" { 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> <a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="badge bg-surface-hover text-content-muted transition-colors hover:text-content">@Icon("external", "h-3 w-3") { getDomainName(book.WebUrl.String) }</a>
} }
</div> </div>
</div>
} }
</div> </div>
<!-- Collections --> <!-- Collections -->
if len(book.Collections) > 0 { if len(book.Collections) > 0 {
<div class="card p-6 mb-6"> <div class="card p-5">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3> <h3 class="mb-4 text-sm font-semibold text-content">Collections</h3>
<div class="flex flex-wrap gap-2.5"> <div class="flex flex-wrap gap-2">
for _, col := range book.Collections { for _, col := range book.Collections {
<a <a href={ "/collections/" + uuidToString(col.ID) } class="flex items-center gap-2 rounded-lg border border-line bg-surface px-3 py-1.5 text-sm transition-colors hover:bg-surface-hover">
href={ "/collections/" + uuidToString(col.ID) }
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> <span class="text-content">{ col.Name }</span>
</a> </a>
} }
</div> </div>
</div> </div>
} }
</div> </div>
</div>
@ProgressSyncModal(user, book) @ProgressSyncModal(user, book)
@NotesHighlightsModal(book) @NotesHighlightsModal(book)
@MetadataEditorModal(book) @MetadataEditorModal(book)
+179 -183
View File
@@ -8,68 +8,62 @@ import (
templ ProgressSyncModal(user User, book handlers.MediaDetail) { templ ProgressSyncModal(user User, book handlers.MediaDetail) {
<div <div
id="progress-sync-modal" id="progress-sync-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
style="background-color: var(--surface-overlay);" style="background-color: var(--surface-overlay);"
> >
<div <div
class="card w-full max-w-4xl my-8 p-6" class="card p-6 w-full max-w-4xl mx-4 my-8"
style="box-shadow: var(--shadow-pop);"
> >
<div class="flex justify-between items-center mb-6"> <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);">
@Icon("sync", "h-5 w-5")
</span>
<div> <div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Sync Progress</h2> <h2 class="text-2xl font-bold tracking-tight text-content">Sync Progress</h2>
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p> <p class="text-sm text-content-muted">{ book.Title }</p>
</div>
</div> </div>
<button <button
@click="hideProgressSyncModal()" @click="hideProgressSyncModal()"
class="icon-btn" class="icon-btn"
aria-label="Close"
> >
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
if book.ActiveConflict != nil { if book.ActiveConflict != nil {
<div <div
class="mb-6 p-4 rounded-xl flex items-start gap-3" class="mb-6 p-4 rounded-lg border border-warning bg-warning/10"
style="background-color: color-mix(in srgb, var(--status-warning) 15%, transparent); border: 1px solid var(--status-warning);"
> >
<span class="shrink-0 mt-0.5" style="color: var(--status-warning);">@Icon("alert", "h-5 w-5")</span> <p class="font-semibold mb-2 flex items-center gap-2 text-content">
<div> @Icon("alert", "h-4 w-4 text-warning")
<p class="font-semibold mb-1" style="color: var(--text-primary);">
Conflict Detected Conflict Detected
</p> </p>
<p class="text-sm" style="color: var(--text-secondary);"> <p class="text-sm text-content-muted">
Progress differs between devices. Choose which version to keep. Progress differs between devices. Choose which version to keep.
</p> </p>
</div> </div>
</div>
<div class="space-y-4"> <div class="space-y-4">
for source, data := range book.ActiveConflict.ConflictData { for source, data := range book.ActiveConflict.ConflictData {
<div class="card p-4"> <div
class="card p-4"
>
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<div> <div>
<span class="badge mb-2" style="background-color: var(--accent); color: #fff;"> <span
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2 bg-brand text-white"
>
{ data.Source } { data.Source }
</span> </span>
<p class="text-xs" style="color: var(--text-secondary);"> <p class="text-xs text-content-muted">
{ FormatInTimezone(data.Timestamp, user.Timezone) } { FormatInTimezone(data.Timestamp, user.Timezone) }
</p> </p>
</div> </div>
<div class="text-right"> <div class="text-right">
if data.Data["percentage"] != nil { if data.Data["percentage"] != nil {
<div class="text-3xl font-bold" style="color: var(--accent);"> <div class="text-3xl font-bold text-brand">
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64) * 100) }% { fmt.Sprintf("%.1f", data.Data["percentage"].(float64) * 100) }%
</div> </div>
} }
</div> </div>
</div> </div>
if data.Data["page"] != nil { if data.Data["page"] != nil {
<div class="text-sm" style="color: var(--text-secondary);"> <div class="text-sm text-content-muted">
if data.Data["total_pages"] != nil { if data.Data["total_pages"] != nil {
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) } Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
} else { } else {
@@ -78,14 +72,14 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
</div> </div>
} }
if data.Data["epubcfi"] != nil { if data.Data["epubcfi"] != nil {
<div class="text-xs mt-1" style="color: var(--text-secondary);"> <div class="text-xs mt-1 text-content-muted">
CFI: { data.Data["epubcfi"].(string) } CFI: { data.Data["epubcfi"].(string) }
</div> </div>
} }
<div class="mt-3"> <div class="mt-3">
<button <button
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"} @click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
class="btn btn-primary" class="btn btn-primary text-sm"
> >
Keep This Keep This
</button> </button>
@@ -94,23 +88,25 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
} }
</div> </div>
} else if book.ReadingProgress != nil { } else if book.ReadingProgress != nil {
<div class="mb-4" style="color: var(--text-secondary);"> <div class="mb-4 text-content-muted">
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p> <p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
</div> </div>
<div class="card p-6 text-center"> <div
<div class="text-5xl font-bold mb-4" style="color: var(--accent);"> class="card p-6 text-center"
>
<div class="text-5xl font-bold mb-4 text-brand">
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }% { fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
</div> </div>
<p class="capitalize text-lg mb-2" style="color: var(--text-primary);"> <p class="capitalize text-lg mb-2 text-content">
{ book.ReadingProgress.LastSyncSource.String } { book.ReadingProgress.LastSyncSource.String }
</p> </p>
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid { if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
<p style="color: var(--text-secondary);"> <p class="text-content-muted">
Page { book.ReadingProgress.CurrentPage.Int32 } of { book.ReadingProgress.TotalPages.Int32 } Page { book.ReadingProgress.CurrentPage.Int32 } of { book.ReadingProgress.TotalPages.Int32 }
</p> </p>
} }
if book.ReadingProgress.LastReadAt.Valid { if book.ReadingProgress.LastReadAt.Valid {
<p class="text-sm mt-2" style="color: var(--text-secondary);"> <p class="text-sm mt-2 text-content-muted">
Last read: { FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) } Last read: { FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
</p> </p>
} }
@@ -131,21 +127,20 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
templ NotesHighlightsModal(book handlers.MediaDetail) { templ NotesHighlightsModal(book handlers.MediaDetail) {
<div <div
id="notes-modal" id="notes-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: var(--surface-overlay);" style="background-color: var(--surface-overlay);"
> >
<div <div
class="card w-full max-w-2xl p-8 text-center" class="card p-8 w-full max-w-2xl mx-4 text-center"
style="box-shadow: var(--shadow-pop);"
> >
<span class="inline-grid place-items-center h-14 w-14 rounded-2xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);"> <div class="mb-4 flex justify-center">
@Icon("edit", "h-7 w-7") @Icon("edit", "h-12 w-12 text-content-muted")
</span> </div>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2> <h2 class="text-2xl font-bold tracking-tight text-content mb-2">Notes & Highlights</h2>
<p class="mb-2" style="color: var(--text-secondary);"> <p class="mb-2 text-content-muted">
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights. This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
</p> </p>
<p class="mb-6" style="color: var(--text-secondary);">Feature coming soon!</p> <p class="mb-6 text-content-muted">Feature coming soon!</p>
<div> <div>
<button <button
@click="hideNotesModal()" @click="hideNotesModal()"
@@ -161,32 +156,25 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
templ MetadataEditorModal(book handlers.MediaDetail) { templ MetadataEditorModal(book handlers.MediaDetail) {
<div <div
id="metadata-editor-modal" id="metadata-editor-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
style="background-color: var(--surface-overlay);" style="background-color: var(--surface-overlay);"
> >
<div <div
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]" class="card w-full max-w-5xl mx-4 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);"> <div class="flex justify-between items-center p-6 border-b border-line flex-shrink-0">
<div class="flex items-center gap-3"> <h2 class="text-2xl font-bold tracking-tight text-content">Edit Metadata</h2>
<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 <button
@click="hideMetadataEditor()" @click="hideMetadataEditor()"
class="icon-btn" class="icon-btn"
aria-label="Close"
> >
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<div class="flex flex-col md:flex-row gap-8 p-6 flex-1 min-h-0 overflow-y-auto"> <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="flex-shrink-0">
<div class="w-64 h-96 rounded-xl overflow-hidden mb-3 cursor-pointer relative group" <div class="w-64 h-96 rounded-lg overflow-hidden shadow-lg mb-3 cursor-pointer relative group bg-surface"
style="background-color: var(--bg-primary);"
@click="document.getElementById('cover-upload-input').click()" @click="document.getElementById('cover-upload-input').click()"
> >
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
@@ -211,70 +199,75 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<div class="w-64 space-y-2"> <div class="w-64 space-y-2">
<button <button
type="button" type="button"
class="btn btn-primary w-full" class="btn btn-primary w-full text-sm"
@click="generateCover()" @click="generateCover()"
x-show="coverGenerating" x-show="coverGenerating"
x-cloak
disabled disabled
> >
@Icon("refresh", "h-4 w-4")
Generating... Generating...
</button> </button>
<button <button
type="button" type="button"
class="btn btn-primary w-full" class="btn btn-primary w-full text-sm"
@click="generateCover()" @click="generateCover()"
x-show="!coverGenerating" x-show="!coverGenerating"
x-cloak
> >
@Icon("refresh", "h-4 w-4")
Generate Cover Generate Cover
</button> </button>
<button <button
type="button" type="button"
class="btn btn-ghost w-full" class="btn btn-secondary w-full text-sm"
@click="removeCover()" @click="removeCover()"
x-show="hasExistingCover || newCoverPreview" x-show="hasExistingCover || newCoverPreview"
x-cloak
> >
Remove Cover Remove Cover
</button> </button>
</div> </div>
</div> </div>
<!-- Accordion Fields (Right) -->
<div class="flex-1 min-w-0 space-y-2"> <div class="flex-1 min-w-0 space-y-2">
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);"> <!-- Basic Info -->
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover" <div class="border rounded-lg border-line">
style="color: var(--text-primary);" <button type="button" class="w-full px-4 py-3 flex justify-between items-center text-content bg-surface"
@click="toggleSection('basic')"> @click="toggleSection('basic')">
<span class="font-semibold">Basic Info</span> <span class="font-semibold">Basic Info</span>
<span class="inline-flex"> <span x-text="openSections.basic ? '▾' : '▸'"></span>
<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> </button>
<div x-show="openSections.basic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);"> <div x-show="openSections.basic" x-cloak x-transition class="p-4 space-y-3 bg-surface">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Title</label>
<input type="text" name="title" value={ book.Title } class="input" /> <input type="text" name="title" value={ book.Title }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Author</label>
<input type="text" name="author" value={ textToString(book.Author) } class="input" /> <input type="text" name="author" value={ textToString(book.Author) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Description</label>
<textarea name="description" rows="3" class="input" <textarea name="description" rows="3"
class="input text-sm"
>{ textToString(book.Description) }</textarea> >{ textToString(book.Description) }</textarea>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Summary</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Summary</label>
<textarea name="summary" rows="2" class="input" <textarea name="summary" rows="2"
class="input text-sm"
>{ textToString(book.Summary) }</textarea> >{ textToString(book.Summary) }</textarea>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Tags</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Tags</label>
<div class="flex flex-wrap gap-1.5 mb-2"> <div class="flex flex-wrap gap-1.5 mb-2">
<template x-for="(tag, idx) in editorTags" :key="idx"> <template x-for="(tag, idx) in editorTags" :key="idx">
<span class="chip"> <span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-brand text-white"
>
<span x-text="tag"></span> <span x-text="tag"></span>
<button type="button" class="hover:bg-surface-hover rounded leading-none" @click="removeEditorTag(idx)">@Icon("close", "h-3 w-3")</button> <button type="button" class="hover:opacity-70 leading-none" @click="removeEditorTag(idx)">@Icon("close", "h-3 w-3")</button>
</span> </span>
</template> </template>
</div> </div>
@@ -288,165 +281,165 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
@keydown="onTagKeydown($event)" @keydown="onTagKeydown($event)"
@blur="hideEditorTagDropdown()" @blur="hideEditorTagDropdown()"
placeholder="Add tag..." placeholder="Add tag..."
class="input" /> class="input text-sm" />
<div <div
x-show="showTagDropdown" x-show="showTagDropdown"
x-transition
x-cloak x-cloak
class="mt-1 w-full rounded-lg border max-h-48 overflow-y-auto" x-transition
style="background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);" class="mt-1 w-full rounded-lg shadow-lg border border-line bg-surface max-h-48 overflow-y-auto"
> >
<template x-for="sug in tagSuggestions" :key="sug.value"> <template x-for="sug in tagSuggestions" :key="sug.value">
<button <button
type="button" type="button"
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover" class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''" :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);'"
@click="selectEditorTagSuggestion(sug.value)" @click="selectEditorTagSuggestion(sug.value)"
> >
<span x-text="sug.value"></span> <span x-text="sug.value"></span>
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span> <span class="text-xs text-content-muted" x-text="sug.count + ' books'"></span>
</button> </button>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Community Rating (0-10)</label>
<input type="number" name="community_rating" min="0" max="10" step="0.1" <input type="number" name="community_rating" min="0" max="10" step="0.1"
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) } value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
class="input" /> class="input text-sm" />
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);"> <!-- Publication -->
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover" <div class="border rounded-lg border-line">
style="color: var(--text-primary);" <button type="button" class="w-full px-4 py-3 flex justify-between items-center text-content bg-surface"
@click="toggleSection('publication')"> @click="toggleSection('publication')">
<span class="font-semibold">Publication</span> <span class="font-semibold">Publication</span>
<span class="inline-flex"> <span x-text="openSections.publication ? '▾' : '▸'"></span>
<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> </button>
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);"> <div x-show="openSections.publication" x-cloak x-transition class="p-4 space-y-3 bg-surface">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Publisher</label>
<input type="text" name="publisher" value={ textToString(book.Publisher) } class="input" /> <input type="text" name="publisher" value={ textToString(book.Publisher) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Date Published</label>
<input type="date" name="date_published" <input type="date" name="date_published"
value={ formatDateForInput(book.DatePublished) } value={ formatDateForInput(book.DatePublished) }
class="input" /> class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Edition</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Edition</label>
<input type="text" name="edition" value={ textToString(book.Edition) } class="input" /> <input type="text" name="edition" value={ textToString(book.Edition) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Language</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Language</label>
<input type="text" name="language" value={ textToString(book.Language) } class="input" /> <input type="text" name="language" value={ textToString(book.Language) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Genre</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Genre</label>
<input type="text" name="genre" value={ textToString(book.Genre) } class="input" /> <input type="text" name="genre" value={ textToString(book.Genre) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Copyright Year</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Copyright Year</label>
<input type="number" name="copyright_year" <input type="number" name="copyright_year"
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) } value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
class="input" /> class="input text-sm" />
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);"> <!-- Series -->
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover" <div class="border rounded-lg border-line">
style="color: var(--text-primary);" <button type="button" class="w-full px-4 py-3 flex justify-between items-center text-content bg-surface"
@click="toggleSection('series')"> @click="toggleSection('series')">
<span class="font-semibold">Series</span> <span class="font-semibold">Series</span>
<span class="inline-flex"> <span x-text="openSections.series ? '▾' : '▸'"></span>
<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> </button>
<div x-show="openSections.series" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);"> <div x-show="openSections.series" x-cloak x-transition class="p-4 space-y-3 bg-surface">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Series</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Series</label>
<input type="text" name="series" value={ textToString(book.Series) } class="input" /> <input type="text" name="series" value={ textToString(book.Series) }
class="input text-sm" />
</div> </div>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Number</label>
<input type="number" name="series_number" <input type="number" name="series_number"
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) } value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
class="input" /> class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Count</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Count</label>
<input type="number" name="series_count" <input type="number" name="series_count"
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) } value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
class="input" /> class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Volume</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Volume</label>
<input type="number" name="volume" <input type="number" name="volume"
value={ fmt.Sprintf("%d", book.Volume.Int32) } value={ fmt.Sprintf("%d", book.Volume.Int32) }
class="input" /> class="input text-sm" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);"> <!-- Identifiers -->
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover" <div class="border rounded-lg border-line">
style="color: var(--text-primary);" <button type="button" class="w-full px-4 py-3 flex justify-between items-center text-content bg-surface"
@click="toggleSection('identifiers')"> @click="toggleSection('identifiers')">
<span class="font-semibold">Identifiers</span> <span class="font-semibold">Identifiers</span>
<span class="inline-flex"> <span x-text="openSections.identifiers ? '▾' : '▸'"></span>
<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> </button>
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);"> <div x-show="openSections.identifiers" x-cloak x-transition class="p-4 space-y-3 bg-surface">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ISBN</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">ISBN</label>
<input type="text" name="isbn" value={ textToString(book.Isbn) } class="input" /> <input type="text" name="isbn" value={ textToString(book.Isbn) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ASIN</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">ASIN</label>
<input type="text" name="asin" value={ textToString(book.Asin) } class="input" /> <input type="text" name="asin" value={ textToString(book.Asin) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Goodreads ID</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Goodreads ID</label>
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) } class="input" /> <input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">OpenLibrary ID</label>
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) } class="input" /> <input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Google Books ID</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Google Books ID</label>
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) } class="input" /> <input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Web URL</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Web URL</label>
<input type="url" name="web_url" value={ textToString(book.WebUrl) } class="input" /> <input type="url" name="web_url" value={ textToString(book.WebUrl) }
class="input text-sm" />
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);"> <!-- Comic/Manga -->
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover" <div class="border rounded-lg border-line">
style="color: var(--text-primary);" <button type="button" class="w-full px-4 py-3 flex justify-between items-center text-content bg-surface"
@click="toggleSection('comic')"> @click="toggleSection('comic')">
<span class="font-semibold">Comic/Manga</span> <span class="font-semibold">Comic/Manga</span>
<span class="inline-flex"> <span x-text="openSections.comic ? '▾' : '▸'"></span>
<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> </button>
<div x-show="openSections.comic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);"> <div x-show="openSections.comic" x-cloak x-transition class="p-4 space-y-3 bg-surface">
<div> <div>
<label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Manga Type</label> <label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Manga Type</label>
<select id="manga_type" name="manga_type" class="input"> <select id="manga_type" name="manga_type"
class="input text-sm">
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option> <option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option> <option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option> <option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
@@ -454,8 +447,9 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</select> </select>
</div> </div>
<div> <div>
<label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Reading Direction</label> <label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Reading Direction</label>
<select id="reading_direction" name="reading_direction" class="input"> <select id="reading_direction" name="reading_direction"
class="input text-sm">
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option> <option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</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> <option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
@@ -463,72 +457,75 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Age Rating</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Age Rating</label>
<input type="text" name="age_rating" value={ textToString(book.AgeRating) } class="input" /> <input type="text" name="age_rating" value={ textToString(book.AgeRating) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Story Arc</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Story Arc</label>
<input type="text" name="story_arc" value={ textToString(book.StoryArc) } class="input" /> <input type="text" name="story_arc" value={ textToString(book.StoryArc) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Imprint</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Imprint</label>
<input type="text" name="imprint" value={ textToString(book.Imprint) } class="input" /> <input type="text" name="imprint" value={ textToString(book.Imprint) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Scan Information</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Scan Information</label>
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) } class="input" /> <input type="text" name="scan_information" value={ textToString(book.ScanInformation) }
class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Metadata Notes</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Metadata Notes</label>
<textarea name="metadata_notes" rows="2" class="input" <textarea name="metadata_notes" rows="2"
class="input text-sm"
>{ textToString(book.MetadataNotes) }</textarea> >{ textToString(book.MetadataNotes) }</textarea>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" name="is_black_and_white" id="is_black_and_white" <input type="checkbox" name="is_black_and_white" id="is_black_and_white"
if book.IsBlackAndWhite.Bool { checked } if book.IsBlackAndWhite.Bool { checked }
class="rounded" /> class="rounded" />
<label for="is_black_and_white" class="text-sm" style="color: var(--text-secondary);">Black & White</label> <label for="is_black_and_white" class="text-sm text-content-muted">Black & White</label>
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);"> <!-- Technical -->
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover" <div class="border rounded-lg border-line">
style="color: var(--text-primary);" <button type="button" class="w-full px-4 py-3 flex justify-between items-center text-content bg-surface"
@click="toggleSection('technical')"> @click="toggleSection('technical')">
<span class="font-semibold">Technical</span> <span class="font-semibold">Technical</span>
<span class="inline-flex"> <span x-text="openSections.technical ? '▾' : '▸'"></span>
<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> </button>
<div x-show="openSections.technical" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);"> <div x-show="openSections.technical" x-cloak x-transition class="p-4 space-y-3 bg-surface">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Page Count</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Page Count</label>
<input type="number" name="page_count" <input type="number" name="page_count"
value={ fmt.Sprintf("%d", book.PageCount.Int32) } value={ fmt.Sprintf("%d", book.PageCount.Int32) }
class="input" /> class="input text-sm" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Contributors (comma-separated)</label>
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" /> <input type="text" name="contributors" value={ stringSliceToString(book.Contributors) }
class="input text-sm" />
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">Format</label>
<input type="text" value={ textToString(book.MimeType) } readonly <input type="text" value={ textToString(book.MimeType) } readonly
class="input opacity-60" /> class="input text-sm opacity-60" />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-1">File Size</label>
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly <input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
class="input opacity-60" /> class="input text-sm opacity-60" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);"> <div class="flex justify-end space-x-3 p-6 border-t border-line flex-shrink-0">
<button <button
@click="hideMetadataEditor()" @click="hideMetadataEditor()"
class="btn btn-secondary" class="btn btn-secondary"
@@ -539,7 +536,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
@click="saveMetadata()" @click="saveMetadata()"
class="btn btn-primary" class="btn btn-primary"
> >
@Icon("save", "h-4 w-4")
Save Save
</button> </button>
</div> </div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -8,10 +8,12 @@ templ BooksGrid(books []handlers.BookInfo, limit int, offset int, count int, cur
@BookCard(book) @BookCard(book)
} }
} else { } else {
<!-- Empty state --> <div class="col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-16 text-center">
<div class="col-span-full text-center py-12" style="color: var(--text-secondary);"> <div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
<p class="text-lg mb-2">📚 No books found</p> @Icon("book", "h-7 w-7")
<p class="text-sm">Try adjusting your filters or add some books to your library.</p> </div>
<p class="text-base font-semibold text-content">No books found</p>
<p class="mt-1 text-sm text-content-muted">Try adjusting your filters or add some books to your library.</p>
</div> </div>
} }
} }
+9 -1
View File
@@ -39,7 +39,15 @@ func BooksGrid(books []handlers.BookInfo, limit int, offset int, count int, curr
} }
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- Empty state --> <div class=\"col-span-full text-center py-12\" style=\"color: var(--text-secondary);\"><p class=\"text-lg mb-2\">📚 No books found</p><p class=\"text-sm\">Try adjusting your filters or add some books to your library.</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-16 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "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, 2, "</div><p class=\"text-base font-semibold text-content\">No books found</p><p class=\"mt-1 text-sm text-content-muted\">Try adjusting your filters or add some books to your library.</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+139 -160
View File
@@ -25,121 +25,29 @@ templ BookShelf(
<title>Library - Bookhoard</title> <title>Library - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body <body class="theme-{ user.Theme }" x-data="bookshelf" x-init="initBookshelf()">
class="theme-{ user.Theme }"
x-data="bookshelf"
x-init="initBookshelf()"
>
@Header(user, "/bookshelf") @Header(user, "/bookshelf")
<div class="w-full px-4 sm:px-6 lg:px-8 py-6"> <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"> <form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid" class="space-y-3">
<!-- Toolbar --> <!-- Toolbar -->
<div class="flex flex-wrap items-center gap-3 mb-6"> <div class="card p-3">
<div class="relative flex-1 min-w-[200px]"> <div class="flex flex-wrap items-center gap-2">
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);"> <!-- Search (prominent) -->
<div class="relative min-w-[14rem] flex-1">
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
@Icon("search", "h-4 w-4") @Icon("search", "h-4 w-4")
</span> </div>
<input <input
name="q" name="q"
type="text" type="text"
class="input pl-10" class="input pl-9"
placeholder="Search all fields…" placeholder="Search all fields…"
/> />
</div> </div>
<select
name="sort" <!-- Library -->
class="input w-auto cursor-pointer" <select name="library_id" class="input w-auto">
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>
}
</div>
</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>
<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 { if len(libraries) == 0 {
<option value="">No libraries available</option> <option value="">No libraries available</option>
} else { } else {
@@ -157,160 +65,231 @@ templ BookShelf(
} }
} }
</select> </select>
<!-- Sort -->
<select
name="sort"
class="input w-auto"
hx-get="/api/media-items/search"
hx-target="#books-grid"
hx-trigger="change"
hx-include="#filter-form"
>
<option value="title ASC">Title (AZ)</option>
<option value="title DESC">Title (ZA)</option>
<option value="author ASC">Author (AZ)</option>
<option value="created_at DESC">Date Added</option>
<option value="page_count DESC">Page Count</option>
</select>
<!-- Filters toggle -->
<button type="button" @click="filtersOpen = !filtersOpen" class="btn btn-secondary">
@Icon("filter", "h-4 w-4")
Filters
</button>
<div class="ml-auto flex items-center gap-1">
<button type="button" @click="showSaveFilterModal()" class="btn btn-ghost" title="Save filter">
@Icon("save", "h-4 w-4")
</button>
<div class="relative">
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-ghost" title="Load filter">
@Icon("folder", "h-4 w-4")
</button>
<!-- Saved filters dropdown -->
<div
x-show="showFiltersDropdown"
x-cloak
@click.outside="showFiltersDropdown = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line bg-surface-raised p-3 shadow-pop"
style="display: none;"
>
<p class="pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">Saved Filters</p>
<div class="space-y-1" id="saved-filters-list">
for _, filter := range savedFilters {
<div class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 truncate text-left text-sm text-content">{ filter.Name }</button>
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-7 w-7" title="Delete filter">
@Icon("trash", "h-4 w-4")
</button>
</div> </div>
}
if len(savedFilters) == 0 {
<p class="py-3 text-center text-sm text-content-muted">No saved filters yet</p>
}
</div>
</div>
</div>
<button type="button" @click="clearFilters()" class="btn btn-ghost" title="Clear filters">
@Icon("close", "h-4 w-4")
</button>
<button type="submit" class="btn btn-primary">
@Icon("search", "h-4 w-4")
Search
</button>
</div>
</div>
</div>
<!-- Collapsible filters panel -->
<div x-show="filtersOpen" x-cloak x-transition class="card p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Author -->
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Author</label>
<input <input
type="text" type="text"
name="author_filter" name="author_filter"
placeholder="Filter by author"
class="input" class="input"
placeholder="Filter by author"
list="author-datalist" list="author-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)" @input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
/> />
<datalist id="author-datalist"></datalist> <datalist id="author-datalist"></datalist>
</div> </div>
<!-- Tags -->
<div class="relative"> <div class="relative">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Tags</label>
<input <input
type="text" type="text"
name="tags_filter" name="tags_filter"
placeholder="Filter by tags"
class="input" class="input"
placeholder="Filter by tags"
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)" @input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
@keydown="onTagFilterKeydown($event)" @keydown="onTagFilterKeydown($event)"
@blur="hideTagDropdown()" @blur="hideTagDropdown()"
/> />
<div <div
x-show="showTagDropdown" x-show="showTagDropdown"
x-transition
x-cloak x-cloak
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto" x-transition
style="background-color: var(--bg-primary); border: 1px solid var(--border);" class="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-line bg-surface-raised shadow-pop"
style="display: none;"
> >
<template x-for="sug in tagSuggestions" :key="sug.value"> <template x-for="sug in tagSuggestions" :key="sug.value">
<button <button
type="button" type="button"
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center" class="flex w-full items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-surface-hover"
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''" :class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''"
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
@click="selectTagSuggestion(sug.value)" @click="selectTagSuggestion(sug.value)"
> >
<span x-text="sug.value"></span> <span class="text-content" x-text="sug.value"></span>
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span> <span class="text-xs text-content-muted" x-text="sug.count + ' books'"></span>
</button> </button>
</template> </template>
</div> </div>
</div> </div>
<!-- Series -->
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Series</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Series</label>
<input <input
type="text" type="text"
name="series_filter" name="series_filter"
placeholder="Filter by series"
class="input" class="input"
placeholder="Filter by series"
list="series-datalist" list="series-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)" @input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
/> />
<datalist id="series-datalist"></datalist> <datalist id="series-datalist"></datalist>
</div> </div>
<!-- Language -->
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Language</label>
<input <input
type="text" type="text"
name="language_filter" name="language_filter"
placeholder="Filter by language"
class="input" class="input"
placeholder="Filter by language"
list="language-datalist" list="language-datalist"
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)" @input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
/> />
<datalist id="language-datalist"></datalist> <datalist id="language-datalist"></datalist>
</div> </div>
<!-- Year range -->
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Year Range</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Year Range</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input type="number" name="year_min" placeholder="From" class="input"/> <input type="number" name="year_min" class="input" placeholder="From"/>
<input type="number" name="year_max" placeholder="To" class="input"/> <input type="number" name="year_max" class="input" placeholder="To"/>
</div> </div>
</div> </div>
<!-- Cover filter -->
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Cover</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Cover</label>
<input <input
type="button" type="button"
id="has-cover-tristate" 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="tristate-btn input cursor-pointer flex items-center justify-center gap-2 font-medium"
:class="{ :class="{ 'state-null': hasCoverState === null, 'state-true': hasCoverState === true, 'state-false': hasCoverState === false }"
'state-null': hasCoverState === null, :value="hasCoverState === null ? 'Cover: Any' : hasCoverState === true ? 'Has Cover' : 'No Cover'"
'state-true': hasCoverState === true,
'state-false': hasCoverState === false
}"
:value="hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
@click="cycleHasCover()" @click="cycleHasCover()"
/> />
<template x-if="hasCoverState !== null"> <template x-if="hasCoverState !== null">
<input <input type="hidden" name="has_cover" :value="hasCoverState ? 'true' : 'false'"/>
type="hidden"
name="has_cover"
:value="hasCoverState ? 'true' : 'false'"
/>
</template> </template>
</div> </div>
</div> </div>
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<button type="submit" @click="filtersOpen = false" class="btn btn-primary w-full">Apply Filters</button>
</div>
</div>
</div> </div>
</form> </form>
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<!-- Books grid -->
<div id="books-grid" class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
@BooksGrid(books, limit, offset, count, currentLibraryID) @BooksGrid(books, limit, offset, count, currentLibraryID)
if errorMessage != "" { if errorMessage != "" {
<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);"> <div class="col-span-full mt-2 rounded-xl border border-danger/40 bg-danger/10 p-4">
<p style="color: var(--text-primary)">{ errorMessage }</p> <p class="text-sm text-danger">{ errorMessage }</p>
</div> </div>
} }
</div> </div>
<div id="pagination" class="mt-6 flex justify-center items-center gap-2">
<!-- Pagination -->
<div id="pagination" class="mt-8 flex items-center justify-center gap-3">
if count > 0 { if count > 0 {
<button <button
type="button" type="button"
class="btn btn-secondary disabled:opacity-40" 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-get={ "/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset-limit) }
hx-target="#books-grid" hx-target="#books-grid"
hx-include="#filter-form" hx-include="#filter-form"
disabled?={ offset <= 0 } disabled?={ offset <= 0 }
> >
@Icon("chevron-left", "h-4 w-4") @Icon("chevron-left", "h-4 w-4")
<span>Prev</span> Previous
</button> </button>
<span class="px-4 py-2 text-sm" style="color: var(--text-secondary);"> <span class="text-sm text-content-muted">Page { offset/limit + 1 }</span>
Page { offset / limit + 1 }
</span>
<button <button
type="button" type="button"
class="btn btn-secondary disabled:opacity-40" 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-get={ "/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset+limit) }
hx-target="#books-grid" hx-target="#books-grid"
hx-include="#filter-form" hx-include="#filter-form"
disabled?={ offset+limit >= count } disabled?={ offset+limit >= count }
> >
<span>Next</span> Next
@Icon("chevron-right", "h-4 w-4") @Icon("chevron-right", "h-4 w-4")
</button> </button>
} }
</div> </div>
</div> </div>
<!-- Save Filter Modal -->
<div <div
x-show="showSaveModal" x-show="showSaveModal"
x-cloak
@click.self="showSaveModal = false" @click.self="showSaveModal = false"
x-transition x-transition
x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4"
class="fixed inset-0 z-[70] flex items-center justify-center p-4" style="background-color: var(--surface-overlay); display: none;"
style="background-color: var(--surface-overlay);"
> >
<div @click.stop class="card rounded-2xl p-6 w-full max-w-md"> <div class="card w-full max-w-md p-6" @click.stop>
<div class="flex justify-between items-center mb-4"> <div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2> <h2 class="text-lg font-bold text-content">Save Filter</h2>
<button @click="showSaveModal = false" class="icon-btn" aria-label="Close"> <button @click="showSaveModal = false" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
@@ -323,9 +302,9 @@ templ BookShelf(
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }" @htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
> >
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Filter Name</label>
<input type="text" name="filter_name" placeholder="My Custom Filter" class="input" required/> <input type="text" name="filter_name" class="input" placeholder="My Custom Filter" 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> <div id="filter-save-error" class="mt-2 hidden rounded-lg bg-danger/10 p-3 text-sm text-danger"></div>
<input type="hidden" name="resource_type" value="media-items"/> <input type="hidden" name="resource_type" value="media-items"/>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
+183 -187
View File
@@ -45,7 +45,7 @@ func BookShelf(
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\" x-data=\"bookshelf\" x-init=\"initBookshelf()\">") 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>Library - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\" x-data=\"bookshelf\" x-init=\"initBookshelf()\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -53,7 +53,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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\" class=\"space-y-3\"><!-- Toolbar --><div class=\"card p-3\"><div class=\"flex flex-wrap items-center gap-2\"><!-- Search (prominent) --><div class=\"relative min-w-[14rem] flex-1\"><div class=\"pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -61,7 +61,146 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><input name=\"q\" type=\"text\" class=\"input pl-9\" placeholder=\"Search all fields…\"></div><!-- Library --><select name=\"library_id\" class=\"input w-auto\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(libraries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<option value=\"\">No libraries available</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
if currentLibraryID == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"\" selected>All Books (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 55, Col: 75}
}
_, 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, ")</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"\">All Books (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 57, Col: 66}
}
_, 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, ")</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, lib := range libraries {
if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 61, Col: 33}
}
_, 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, 10, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 61, Col: 55}
}
_, 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, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 61, Col: 75}
}
_, 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, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 63, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 63, Col: 46}
}
_, 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, 15, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 63, Col: 66}
}
_, 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, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</select><!-- Sort --><select name=\"sort\" class=\"input w-auto\" hx-get=\"/api/media-items/search\" hx-target=\"#books-grid\" hx-trigger=\"change\" hx-include=\"#filter-form\"><option value=\"title ASC\">Title (AZ)</option> <option value=\"title DESC\">Title (ZA)</option> <option value=\"author ASC\">Author (AZ)</option> <option value=\"created_at DESC\">Date Added</option> <option value=\"page_count DESC\">Page Count</option></select><!-- Filters toggle --><button type=\"button\" @click=\"filtersOpen = !filtersOpen\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -69,7 +208,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span>Filters</span></button> <button type=\"button\" @click=\"showSaveFilterModal()\" class=\"btn btn-secondary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Filters</button><div class=\"ml-auto flex items-center gap-1\"><button type=\"button\" @click=\"showSaveFilterModal()\" class=\"btn btn-ghost\" title=\"Save filter\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -77,7 +216,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>Save</span></button><div class=\"relative\"><button type=\"button\" @click=\"toggleFiltersDropdown()\" data-load-filter-btn class=\"btn btn-secondary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</button><div class=\"relative\"><button type=\"button\" @click=\"toggleFiltersDropdown()\" data-load-filter-btn class=\"btn btn-ghost\" title=\"Load filter\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -85,38 +224,38 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</button><!-- Saved filters dropdown --><div x-show=\"showFiltersDropdown\" x-cloak @click.outside=\"showFiltersDropdown = false\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"absolute right-0 top-full mt-2 w-80 rounded-xl border border-line bg-surface-raised p-3 shadow-pop\" style=\"display: none;\"><p class=\"pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted\">Saved Filters</p><div class=\"space-y-1\" id=\"saved-filters-list\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, filter := range savedFilters { for _, filter := range savedFilters {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"flex items-center justify-between p-2 rounded-lg hover:bg-surface-hover\" data-filter-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover\" data-filter-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 116, Col: 169}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 truncate text-left text-sm text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 117, Col: 143}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn h-8 w-8\" title=\"Delete filter\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn h-7 w-7\" title=\"Delete filter\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -124,22 +263,18 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(savedFilters) == 0 { if len(savedFilters) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"text-sm py-4 text-center\" style=\"color: var(--text-secondary);\">No saved filters yet</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p class=\"py-3 text-center text-sm text-content-muted\">No saved filters yet</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div><button type=\"button\" @click=\"clearFilters()\" class=\"btn btn-ghost\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></div><button type=\"button\" @click=\"clearFilters()\" class=\"btn btn-ghost\" title=\"Clear filters\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -147,154 +282,15 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button></div><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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "Search</button></div></div></div><!-- Collapsible filters panel --><div x-show=\"filtersOpen\" x-cloak x-transition class=\"card p-4\"><div class=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\"><!-- Author --><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Author</label> <input type=\"text\" name=\"author_filter\" class=\"input\" placeholder=\"Filter by author\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><!-- Tags --><div class=\"relative\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Tags</label> <input type=\"text\" name=\"tags_filter\" class=\"input\" placeholder=\"Filter by tags\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-cloak x-transition class=\"absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-line bg-surface-raised shadow-pop\" style=\"display: none;\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"flex w-full items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-surface-hover\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''\" @click=\"selectTagSuggestion(sug.value)\"><span class=\"text-content\" x-text=\"sug.value\"></span> <span class=\"text-xs text-content-muted\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><!-- Series --><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Series</label> <input type=\"text\" name=\"series_filter\" class=\"input\" placeholder=\"Filter by series\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><!-- Language --><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Language</label> <input type=\"text\" name=\"language_filter\" class=\"input\" placeholder=\"Filter by language\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><!-- Year range --><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" class=\"input\" placeholder=\"From\"> <input type=\"number\" name=\"year_max\" class=\"input\" placeholder=\"To\"></div></div><!-- Cover filter --><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn input cursor-pointer flex items-center justify-center gap-2 font-medium\" :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()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div></div></form><!-- Books grid --><div id=\"books-grid\" class=\"mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(libraries) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"\">No libraries available</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
if currentLibraryID == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<option value=\"\" selected>All Books (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 147, Col: 77}
}
_, 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, 18, ")</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<option value=\"\">All Books (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 149, Col: 68}
}
_, 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, 20, ")</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
for _, lib := range libraries {
if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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/bookshelf.templ`, Line: 153, Col: 35}
}
_, 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, 22, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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/bookshelf.templ`, Line: 153, Col: 57}
}
_, 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, 23, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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/bookshelf.templ`, Line: 153, Col: 77}
}
_, 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, 24, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, 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, 26, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 48}
}
_, 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, 27, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
}
_, 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, 28, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -303,53 +299,53 @@ func BookShelf(
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if errorMessage != "" { if errorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<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)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"col-span-full mt-2 rounded-xl border border-danger/40 bg-danger/10 p-4\"><p class=\"text-sm text-danger\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 59} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 246, Col: 52}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><!-- Pagination --><div id=\"pagination\" class=\"mt-8 flex items-center justify-center gap-3\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if count > 0 { if count > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit)) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset-limit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 278, Col: 126} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 257, Col: 158}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if offset <= 0 { if offset <= 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " disabled") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " disabled")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -357,43 +353,43 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Previous</button> <span class=\"text-sm text-content-muted\">Page ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 287, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 265, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit)) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/search?library_id=" + currentLibraryID + "&limit=" + fmt.Sprintf("%d", limit) + "&offset=" + fmt.Sprintf("%d", offset+limit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 292, Col: 126} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 158}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if offset+limit >= count { if offset+limit >= count {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " disabled") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " disabled")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "><span>Next</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">Next")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -401,12 +397,12 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition 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-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=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div></div><!-- Save Filter Modal --><div x-show=\"showSaveModal\" x-cloak @click.self=\"showSaveModal = false\" x-transition class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay); display: none;\"><div class=\"card w-full max-w-md p-6\" @click.stop><div class=\"mb-4 flex items-center justify-between\"><h2 class=\"text-lg font-bold text-content\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -414,7 +410,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><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=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Filter Name</label> <input type=\"text\" name=\"filter_name\" class=\"input\" placeholder=\"My Custom Filter\" required><div id=\"filter-save-error\" class=\"mt-2 hidden rounded-lg bg-danger/10 p-3 text-sm text-danger\"></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=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+16 -17
View File
@@ -13,37 +13,36 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ pageTitle } - Bookhoard</title> <title>{ pageTitle } - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }">
@Header(user, backUrl) @Header(user, backUrl)
<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="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-3"> <div class="w-full px-4 sm:px-6 lg:px-8 py-2.5">
<a href={ backUrl } class="btn btn-ghost"> <a href={ backUrl } class="inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
@Icon("arrow-left", "h-4 w-4") @Icon("arrow-left", "h-4 w-4")
<span>{ backLabel }</span> { backLabel }
</a> </a>
</div> </div>
</div> </div>
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-wrap items-center gap-3 mb-6"> <div class="mb-6 flex flex-wrap items-center gap-3">
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);"> <span class="chip">{ badgeIcon } { badgeLabel }</span>
{ badgeIcon } { badgeLabel } <h1 class="text-2xl font-bold tracking-tight text-content">{ title }</h1>
</span> <span class="text-sm text-content-muted">{ 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> </div>
if len(books) > 0 { if len(books) > 0 {
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4"> <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
for _, book := range books { for _, book := range books {
@BookCard(book) @BookCard(book)
} }
</div> </div>
} else { } else {
<div class="card text-center py-16 px-6"> <div class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center">
<div class="text-5xl mb-4">{ emptyIcon }</div> <div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Found</h3> @Icon("book", "h-7 w-7")
<p style="color: var(--text-secondary)">{ emptyMessage }</p> </div>
<h3 class="text-lg font-semibold text-content">No Books Found</h3>
<p class="mt-1 text-sm text-content-muted">{ emptyMessage }</p>
</div> </div>
} }
</div> </div>
+30 -39
View File
@@ -47,7 +47,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -55,20 +55,20 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur\"><div class=\"w-full px-4 sm:px-6 lg:px-8 py-2.5\"><a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 21, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"btn btn-ghost\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -76,77 +76,73 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 23, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</a></div></div><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex flex-wrap items-center gap-3\"><span class=\"chip\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h1 class=\"text-2xl font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 30, 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_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h1><span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h1><span class=\"text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books))) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " books</span></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " books</span></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(books) > 0 { if len(books) > 0 {
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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -156,43 +152,38 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card text-center py-16 px-6\"><div class=\"text-5xl mb-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book", "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, 13, "</div><h3 class=\"text-lg font-semibold text-content\">No Books Found</h3><p class=\"mt-1 text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 45, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div>")
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: 46, 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, 15, "</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -200,7 +191,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+43 -40
View File
@@ -1,20 +1,15 @@
package templates package templates
templ CollectionModal(collection CollectionData) { templ CollectionModal(collection CollectionData) {
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);"> <div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-6"> <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);">
@Icon("folder", "h-5 w-5")
</span>
if collection.ID != "" { if collection.ID != "" {
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Collection</h2> <h2 class="text-xl font-bold text-content">Edit Collection</h2>
} else { } else {
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2> <h2 class="text-xl font-bold text-content">Create Collection</h2>
} }
</div> <button type="button" @click="closeCollectionModal()" class="icon-btn">
<button type="button" @click="closeCollectionModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
@@ -25,7 +20,7 @@ templ CollectionModal(collection CollectionData) {
> >
<input type="hidden" name="id" value={ collection.ID }/> <input type="hidden" name="id" value={ collection.ID }/>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Name</label>
<input <input
type="text" type="text"
name="name" name="name"
@@ -36,7 +31,7 @@ templ CollectionModal(collection CollectionData) {
/> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Description</label>
<textarea <textarea
name="description" name="description"
class="input" class="input"
@@ -45,31 +40,35 @@ templ CollectionModal(collection CollectionData) {
>{ collection.Description }</textarea> >{ collection.Description }</textarea>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Icon</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Icon</label>
<!-- Search/Text Input -->
<input <input
type="text" type="text"
id="icon-search" id="icon-search"
class="input mb-2" class="input mb-2"
placeholder="Search or type emoji..." placeholder="🔍 Search or type emoji..."
maxlength="4" maxlength="4"
@input="filterIcons($event.target.value)" oninput="filterIcons(this.value)"
@focus="showAllIcons()" onfocus="showAllIcons()"
/> />
<!-- Hidden input for form submission -->
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/> <input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
<!-- Emoji Grid -->
<div <div
id="icon-grid" id="icon-grid"
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border" class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border border-line bg-surface"
style="background-color: var(--bg-primary); border-color: var(--border);" >
></div> <!-- Populated dynamically by JavaScript -->
</div>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Color</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Color</label>
<div class="flex gap-2"> <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('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></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('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></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('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></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('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></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> <button type="button" @click="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
</div> </div>
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/> <input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
</div> </div>
@@ -84,7 +83,7 @@ templ CollectionModal(collection CollectionData) {
hx-redirect="/collections" hx-redirect="/collections"
> >
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Name</label>
<input <input
type="text" type="text"
name="name" name="name"
@@ -94,7 +93,7 @@ templ CollectionModal(collection CollectionData) {
/> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Description</label>
<textarea <textarea
name="description" name="description"
class="input" class="input"
@@ -103,31 +102,35 @@ templ CollectionModal(collection CollectionData) {
></textarea> ></textarea>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Icon</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Icon</label>
<!-- Search/Text Input -->
<input <input
type="text" type="text"
id="icon-search" id="icon-search"
class="input mb-2" class="input mb-2"
placeholder="Search or type emoji..." placeholder="🔍 Search or type emoji..."
maxlength="4" maxlength="4"
@input="filterIcons($event.target.value)" oninput="filterIcons(this.value)"
@focus="showAllIcons()" onfocus="showAllIcons()"
/> />
<!-- Hidden input for form submission -->
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/> <input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
<!-- Emoji Grid -->
<div <div
id="icon-grid" id="icon-grid"
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border" class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border border-line bg-surface"
style="background-color: var(--bg-primary); border-color: var(--border);" >
></div> <!-- Populated dynamically by JavaScript -->
</div>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Color</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Color</label>
<div class="flex gap-2"> <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('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></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('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></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('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></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('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></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> <button type="button" @click="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
</div> </div>
<input type="hidden" name="color" id="collection-color" value="blue"/> <input type="hidden" name="color" id="collection-color" value="blue"/>
</div> </div>
+22 -30
View File
@@ -29,30 +29,22 @@ func CollectionModal(collection CollectionData) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center\" 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\">")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if collection.ID != "" { if collection.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Collection</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"text-xl font-bold text-content\">Edit Collection</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Collection</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold text-content\">Create Collection</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><button type=\"button\" @click=\"closeCollectionModal()\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<button type=\"button\" @click=\"closeCollectionModal()\" class=\"icon-btn\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -60,113 +52,113 @@ func CollectionModal(collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if collection.ID != "" { if collection.ID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form hx-put=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<form hx-put=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 18, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 21, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Name</label> <input type=\"text\" name=\"name\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 27, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 40, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"input mb-2\" 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=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 55, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border border-line bg-surface\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 73, Col: 86}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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 text-content-muted mb-2\">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 text-content-muted mb-2\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"input mb-2\" 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=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 116, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 117, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border border-line bg-surface\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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 btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+41 -42
View File
@@ -9,7 +9,6 @@ templ CollectionRules(user User, collection CollectionData) {
<title>Collection Rules - { collection.Name } - Bookhoard</title> <title>Collection Rules - { collection.Name } - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ user.Theme }" x-data="collectionRules" x-init="initCollectionRules('{ collection.ID }')"> <body class="theme-{ user.Theme }" x-data="collectionRules" x-init="initCollectionRules('{ collection.ID }')">
@Header(user, "/collections") @Header(user, "/collections")
@@ -20,32 +19,32 @@ templ CollectionRules(user User, collection CollectionData) {
Back to Collection Back to Collection
</button> </button>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<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 class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
<div> <div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1> <h1 class="text-2xl font-bold tracking-tight text-content">{ collection.Name }</h1>
<p style="color: var(--text-secondary)">Auto-Assign Rules</p> <p class="text-content-muted">Auto-Assign Rules</p>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-8"> <div class="mb-8">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2> <h2 class="text-2xl font-bold tracking-tight text-content mb-4">Existing Rules</h2>
<div id="rules-container" class="space-y-4"> <div id="rules-container" class="space-y-4">
<div id="no-rules" class="card p-8 text-center"> <div id="no-rules" class="card p-6 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);"> <div class="mb-2 flex justify-center">
@Icon("filter", "h-6 w-6") @Icon("list", "h-10 w-10 text-content-muted")
</span> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3> <h3 class="text-lg font-semibold mb-2 text-content">No Rules Yet</h3>
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p> <p class="text-content-muted">Create auto-assign rules to automatically add books to this collection</p>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-8"> <div class="mb-8">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2> <h2 class="text-2xl font-bold tracking-tight text-content mb-4">Create New Rule</h2>
<form id="rule-form" @submit="handleCreateRule($event)" class="card p-6 space-y-6"> <form id="rule-form" @submit="handleCreateRule($event)">
<input type="hidden" id="collection-id" value={ collection.ID }/> <input type="hidden" id="collection-id" value="{ collection.ID }"/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Field</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Field</label>
<select <select
id="rule-field" id="rule-field"
required required
@@ -62,7 +61,7 @@ templ CollectionRules(user User, collection CollectionData) {
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Operator</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Operator</label>
<select <select
id="rule-operator" id="rule-operator"
required required
@@ -80,8 +79,8 @@ templ CollectionRules(user User, collection CollectionData) {
</select> </select>
</div> </div>
</div> </div>
<div> <div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Value</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Value</label>
<input <input
type="text" type="text"
id="rule-value" id="rule-value"
@@ -90,7 +89,7 @@ templ CollectionRules(user User, collection CollectionData) {
placeholder="e.g., Science Fiction" placeholder="e.g., Science Fiction"
/> />
</div> </div>
<div> <div class="mb-6">
<label class="flex items-center space-x-3 cursor-pointer"> <label class="flex items-center space-x-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -98,16 +97,16 @@ templ CollectionRules(user User, collection CollectionData) {
checked checked
class="w-5 h-5 rounded" class="w-5 h-5 rounded"
/> />
<span class="text-sm font-medium" style="color: var(--text-primary)">Enable Rule</span> <span class="text-content">Enable Rule</span>
</label> </label>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p> <p class="text-xs mt-1 text-content-muted">Uncheck to disable without deleting</p>
</div> </div>
<div> <div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Priority</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Priority</label>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input type="radio" name="priority" value="1" class="w-4 h-4"/> <input type="radio" name="priority" value="1" class="w-4 h-4"/>
<span class="text-sm" style="color: var(--text-primary)">High</span> <span class="text-content">High</span>
</label> </label>
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input <input
@@ -117,7 +116,7 @@ templ CollectionRules(user User, collection CollectionData) {
checked checked
class="w-4 h-4" class="w-4 h-4"
/> />
<span class="text-sm" style="color: var(--text-primary)">Medium</span> <span class="text-content">Medium</span>
</label> </label>
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input <input
@@ -126,16 +125,16 @@ templ CollectionRules(user User, collection CollectionData) {
value="3" value="3"
class="w-4 h-4" class="w-4 h-4"
/> />
<span class="text-sm" style="color: var(--text-primary)">Low</span> <span class="text-content">Low</span>
</label> </label>
</div> </div>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" @click="testRule()" class="btn btn-secondary"> <button type="button" @click="testRule()" class="btn btn-secondary">
@Icon("search", "h-4 w-4") @Icon("play", "h-4 w-4")
Test Rule Test Rule
</button> </button>
<button type="button" @click="clearForm()" class="btn btn-ghost"> <button type="button" @click="clearForm()" class="btn btn-secondary">
Clear Clear
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
@@ -144,20 +143,20 @@ templ CollectionRules(user User, collection CollectionData) {
</button> </button>
</div> </div>
</form> </form>
<div id="test-results" class="hidden card p-6 mt-4 mb-6"> <div id="test-results" class="hidden card p-6 mb-6">
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3> <h3 class="font-semibold mb-3 text-content">Rule Test Results</h3>
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p> <p class="text-sm mb-2 text-content-muted">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 id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
</div> </div>
</div> </div>
<div class="card p-6"> <div class="card p-6">
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2> <h2 class="text-xl font-bold mb-4 text-content">Rule Examples</h2>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<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 class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold bg-brand text-surface">1</div>
<div> <div>
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p> <p class="font-medium text-content">Add all Science Fiction books</p>
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;"> <code class="block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded">
Field: genre Field: genre
<br/> <br/>
Operator: equals Operator: equals
@@ -167,10 +166,10 @@ templ CollectionRules(user User, collection CollectionData) {
</div> </div>
</div> </div>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<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 class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold bg-brand text-surface">2</div>
<div> <div>
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p> <p class="font-medium text-content">Add books from a specific series</p>
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;"> <code class="block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded">
Field: series Field: series
<br/> <br/>
Operator: starts with Operator: starts with
@@ -180,10 +179,10 @@ templ CollectionRules(user User, collection CollectionData) {
</div> </div>
</div> </div>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<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 class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold bg-brand text-surface">3</div>
<div> <div>
<p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p> <p class="font-medium text-content">Add books published in a year range</p>
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;"> <code class="block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded">
Field: copyright_year Field: copyright_year
<br/> <br/>
Operator: greater than Operator: greater than
+11 -24
View File
@@ -42,7 +42,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\" x-data=\"collectionRules\" x-init=\"initCollectionRules('{ collection.ID }')\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\" x-data=\"collectionRules\" x-init=\"initCollectionRules('{ collection.ID }')\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -58,62 +58,49 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Collection</button><div class=\"flex items-center gap-4\"><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 }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Collection</button><div class=\"flex items-center gap-4\"><div class=\"text-4xl\" style=\"color: { collection.Color }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 176} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 81}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div><h1 class=\"text-2xl font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h1><p style=\"color: var(--text-secondary)\">Auto-Assign Rules</p></div></div></div><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-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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h1><p class=\"text-content-muted\">Auto-Assign Rules</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold tracking-tight text-content mb-4\">Existing Rules</h2><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-6 text-center\"><div class=\"mb-2 flex justify-center\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("filter", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("list", "h-10 w-10 text-content-muted").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</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></div></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)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><h3 class=\"text-lg font-semibold mb-2 text-content\">No Rules Yet</h3><p class=\"text-content-muted\">Create auto-assign rules to automatically add books to this collection</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold tracking-tight text-content mb-4\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\"><input type=\"hidden\" id=\"collection-id\" value=\"{ collection.ID }\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-content\">Enable Rule</span></label><p class=\"text-xs mt-1 text-content-muted\">Uncheck to disable without deleting</p></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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 class=\"text-content\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-content\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-content\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string templ_7745c5c3_Err = Icon("play", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 45, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><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=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><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=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><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=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <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><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 class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <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 btn-secondary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-secondary\">Clear</button> <button type=\"submit\" 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, 9, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -121,7 +108,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Add Rule</button></div></form><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\"><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\"><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 rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><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 rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><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 rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mb-6\"><h3 class=\"font-semibold mb-3 text-content\">Rule Test Results</h3><p class=\"text-sm mb-2 text-content-muted\">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\"><h2 class=\"text-xl font-bold mb-4 text-content\">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 bg-brand text-surface\">1</div><div><p class=\"font-medium text-content\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></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 bg-brand text-surface\">2</div><div><p class=\"font-medium text-content\">Add books from a specific series</p><code class=\"block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></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 bg-brand text-surface\">3</div><div><p class=\"font-medium text-content\">Add books published in a year range</p><code class=\"block mt-1 text-sm text-content-muted bg-surface-raised px-2 py-1 rounded\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+87 -296
View File
@@ -11,93 +11,59 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
<title>Collections - Bookhoard</title> <title>Collections - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }"> <body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
@Header(user, "/collections") @Header(user, "/collections")
<div id="modal-container"></div> <div id="modal-container"></div>
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full 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="mb-8 flex flex-wrap items-end justify-between 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> <div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">My Collections</h1> <div class="mb-1 flex items-center gap-2">
<p class="text-sm" style="color: var(--text-secondary)">Organize your books into custom collections</p> @Icon("folder", "h-6 w-6 text-brand")
<h1 class="text-2xl font-bold tracking-tight text-content">My Collections</h1>
</div> </div>
<p class="text-sm text-content-muted">Organize your books into custom collections</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button hx-get="/collections/restore-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-secondary">
hx-get="/collections/restore-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn btn-secondary"
>
@Icon("refresh", "h-4 w-4") @Icon("refresh", "h-4 w-4")
<span>Restore System</span> Restore System
</button> </button>
<button <button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-primary">
hx-get="/collections/create-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4") @Icon("plus", "h-4 w-4")
<span>New Collection</span> New Collection
</button> </button>
</div> </div>
</div> </div>
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div id="collections-list" class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
if len(collections) == 0 { if len(collections) == 0 {
<div class="card text-center py-16 px-6 col-span-full"> <div class="col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center">
<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);"> <div class="mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted">
@Icon("folder", "h-7 w-7") @Icon("folder", "h-7 w-7")
</div> </div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3> <h3 class="text-lg font-semibold text-content">No Collections Yet</h3>
<p class="mb-5" style="color: var(--text-secondary)">Create collections to organize your books</p> <p class="mb-4 mt-1 text-sm text-content-muted">Create collections to organize your books.</p>
<button <button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML" class="btn btn-primary">
hx-get="/collections/create-modal" Create Your First Collection
hx-target="#modal-container"
hx-swap="innerHTML"
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4")
<span>Create Your First Collection</span>
</button> </button>
</div> </div>
} }
for _, col := range collections { for _, col := range collections {
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block"> <div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
<div <div class="card cursor-pointer p-5" style={ "border-left: 4px solid " + col.Color } data-color={ col.Color }>
class="card p-6 rounded-2xl border-l-4 cursor-pointer" <div class="mb-3 flex items-start justify-between">
data-color={ col.Color } <div class="text-2xl">{ col.Icon }</div>
>
<div class="flex justify-between items-start mb-4">
<div class="text-3xl">{ col.Icon }</div>
<div class="flex gap-1"> <div class="flex gap-1">
<button <button hx-get={ "/collections/" + col.ID + "/edit-modal" } hx-target="#modal-container" hx-swap="innerHTML" class="icon-btn h-8 w-8" title="Edit">
hx-get={ "/collections/" + col.ID + "/edit-modal" }
hx-target="#modal-container"
hx-swap="innerHTML"
class="icon-btn"
aria-label="Edit collection"
>
@Icon("edit", "h-4 w-4") @Icon("edit", "h-4 w-4")
</button> </button>
<button <button hx-delete={ "/api/collections/" + col.ID + "" } hx-redirect="/collections" hx-confirm="Are you sure you want to delete this collection?" class="icon-btn h-8 w-8 hover:text-danger" title="Delete">
hx-delete={ "/api/collections/" + col.ID + "" }
hx-redirect="/collections"
hx-confirm="Are you sure you want to delete this collection?"
class="icon-btn"
aria-label="Delete collection"
>
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
</button> </button>
</div> </div>
</div> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3> <h3 class="mb-1 text-base font-semibold text-content">{ col.Name }</h3>
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p> <p class="text-sm text-content-muted">{ col.Description }</p>
</div> </div>
</div> </div>
} }
@@ -117,305 +83,130 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<title>{ collection.Name } - Bookhoard</title> <title>{ collection.Name } - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }"> <body x-data="collections" x-init="initCollectionsPage()" class="theme-{ user.Theme }">
@Header(user, "/collections") @Header(user, "/collections")
@LibrarySwitcher(libData, libraryID) @LibrarySwitcher(libData, libraryID)
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/collections" class="btn btn-secondary mb-4"> <a href="/collections" class="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content">
@Icon("arrow-left", "h-4 w-4") @Icon("arrow-left", "h-4 w-4")
<span>Back to Collections</span> Back to Collections
</a> </a>
<div class="flex items-center gap-4"> <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 }">{ collection.Icon }</div> <div class="flex h-12 w-12 items-center justify-center rounded-xl text-2xl" style={ "background-color: " + collection.Color + "20; color: " + collection.Color }>{ collection.Icon }</div>
<div> <div>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ collection.Name }</h1> <h1 class="text-2xl font-bold tracking-tight text-content">{ collection.Name }</h1>
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p> <p class="text-sm text-content-muted">{ collection.Description }</p>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-6 flex flex-wrap justify-between items-center gap-4"> <div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3"> <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> <h2 class="text-sm font-semibold uppercase tracking-wide text-content-muted">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> <span id="selected-count" class="hidden badge bg-brand/15 text-brand">0 selected</span>
</div> </div>
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-1 justify-end gap-2">
<div class="flex-1 min-w-[200px] max-w-md"> <div class="relative w-full max-w-xs">
<input <div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
type="text" @Icon("search", "h-4 w-4")
id="collection-search"
placeholder="Search within collection..."
@input="filterCollectionBooks()"
class="input"
/>
</div> </div>
if !collection.IsSystem { <input type="text" id="collection-search" placeholder="Search within collection…" onkeyup="filterCollectionBooks()" class="input pl-9"/>
<button </div>
@click="requestBulkRemove()" <button id="bulk-remove-btn" disabled class="btn btn-danger disabled:opacity-40 disabled:cursor-not-allowed">
:disabled="selectedBooks.length === 0"
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
class="btn btn-danger"
>
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
<span>Remove Selected</span> Remove Selected
</button> </button>
<button <button @click="$store.bookPicker.open()" class="btn btn-primary">
@click="$store.bookPicker.open()"
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4") @Icon("plus", "h-4 w-4")
<span>Add Books</span> Add Books
</button> </button>
}
</div> </div>
</div> </div>
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div id="books-container" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
if len(books) == 0 { if len(books) == 0 {
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div> <div id="empty-state" class="col-span-full rounded-2xl border border-dashed border-line py-16 text-center text-sm text-content-muted">No books in this collection yet.</div>
} }
for _, book := range books { for _, book := range books {
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }> <div class="card flex gap-4 p-4">
<div class="flex gap-4"> <div class="flex flex-shrink-0 items-center pt-1">
if !collection.IsSystem { <input type="checkbox" onchange="toggleBookForRemoval('{ book.MediaItemID }')" class="h-5 w-5 rounded border-line"/>
<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> </div>
} <a href={ "/media/" + book.MediaItemID } class="flex min-w-0 flex-1 gap-3">
<div class="flex-1 min-w-0"> <div class="flex-shrink-0 w-12 sm:w-16">
<a href={ "/media/" + book.MediaItemID }>
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
{ book.Title }
</h3>
</a>
if book.Author != "" {
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
by { book.Author }
</p>
}
</div>
<div class="flex-shrink-0 w-16 sm:w-20">
<a href={ "/media/" + book.MediaItemID }>
if book.CoverImagePath != "" { if book.CoverImagePath != "" {
<img <img src={ book.CoverImagePath } alt="Cover" class="aspect-[3/4] w-full rounded object-cover shadow-card" onerror="this.src='/static/placeholder-book.svg'"/>
src={ book.CoverImagePath }
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else { } else {
<img <img src="/static/placeholder-book.svg" alt="Cover" class="aspect-[3/4] w-full rounded object-cover shadow-card"/>
src="/static/placeholder-book.svg"
alt="Cover"
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
/>
} }
</a>
</div> </div>
</div> <div class="min-w-0 flex-1">
if !collection.IsSystem { <h3 class="line-clamp-2 text-sm font-semibold text-content">{ book.Title }</h3>
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);"> if book.Author != "" {
<button <p class="mt-0.5 line-clamp-1 text-xs text-content-muted">by { book.Author }</p>
@click="requestRemoveBook('{ book.MediaItemID }')" }
class="btn btn-secondary w-full" <button @click={ "removeBook('" + book.MediaItemID + "')" } class="mt-2 inline-flex items-center gap-1 text-xs text-content-muted transition-colors hover:text-danger">
> @Icon("trash", "h-3 w-3")
@Icon("trash", "h-4 w-4") Remove
<span>Remove from Collection</span>
</button> </button>
</div> </div>
} </a>
</div> </div>
} }
</div> </div>
</div> </div>
if !collection.IsSystem {
<div <!-- Book picker modal -->
x-data="bookPicker" <div x-data="bookPicker" @keyup.escape.window="$store.bookPicker.close()" class="fixed inset-0 z-50 flex items-center justify-center" style="display: none;">
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 <div
@click.stop @click.stop
x-show="$store.bookPicker.isOpen" x-show="$store.bookPicker.isOpen"
x-cloak
x-transition:enter="transition ease-out duration-200" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95" x-transition:leave-end="opacity-0 scale-95"
class="card rounded-2xl w-full max-w-6xl mx-4 my-8" class="card my-8 w-full max-w-6xl overflow-hidden"
style="display: none; box-shadow: var(--shadow-pop);" style="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="icon-btn"
aria-label="Close"
> >
<div class="flex items-center justify-between border-b border-line p-5">
<h2 class="text-lg font-bold text-content">Add Books to Collection</h2>
<button @click="$store.bookPicker.close()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<div <div class="flex flex-wrap items-center gap-3 border-b border-line p-4">
class="p-4 border-b" <div class="min-w-[12rem] flex-1">
style="border-color: var(--border);" <input type="text" name="search" placeholder="Search books…" class="input" 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 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>
<div class="flex-1 min-w-[150px]"> <div class="min-w-[10rem] flex-1">
<input <input type="text" name="author_filter" placeholder="Author" class="input" 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"/>
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>
<div class="flex-1 min-w-[150px]"> <div class="min-w-[10rem] flex-1">
<input <input type="text" name="genre_filter" placeholder="Genre" class="input" 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"/>
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> </div>
<button <button @click="$store.bookPicker.clearFilters()" class="btn btn-ghost">
@click="$store.bookPicker.clearFilters()"
class="btn btn-ghost"
>
@Icon("close", "h-4 w-4") @Icon("close", "h-4 w-4")
<span>Clear</span> Clear
</button> </button>
<form id="filter-form" class="hidden">
<input type="hidden" name="limit" value="50"/> <input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/> <input type="hidden" name="offset" value="0"/>
</form>
</div> </div>
</div> <div id="book-picker-grid" class="grid max-h-96 grid-cols-2 gap-4 overflow-y-auto p-4 md:grid-cols-4 lg:grid-cols-6"></div>
<div <div class="flex items-center justify-between border-t border-line p-4">
id="book-picker-grid" <div class="text-sm text-content-muted"><span x-text="$store.bookPicker.selectedCount"></span> books selected</div>
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"> <div class="flex gap-2">
<button <button @click="$store.bookPicker.close()" class="btn btn-secondary">Cancel</button>
@click="$store.bookPicker.close()" <button @click="$store.bookPicker.submit()" class="btn btn-primary">Add Selected Books</button>
class="btn btn-secondary"
>
Cancel
</button>
<button
@click="$store.bookPicker.submit()"
class="btn btn-primary"
>
Add Selected Books
</button>
</div> </div>
</div> </div>
</div> </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> </body>
<div <div id="collection-data" data-id={ collection.ID } data-library-id={ libraryID } style="display: none;"></div>
id="collection-data"
data-id={ collection.ID }
data-library-id={ libraryID }
data-is-system={ collection.IsSystem }
style="display: none;"
></div>
</html> </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>
}
}
+189 -361
View File
@@ -31,7 +31,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collections - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">") 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>Collections - 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -39,15 +39,15 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"modal-container\"></div><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex flex-wrap items-end justify-between gap-4\"><div><div class=\"mb-1 flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("folder", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("folder", "h-6 w-6 text-brand").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h1 class=\"text-2xl font-bold tracking-tight text-content\">My Collections</h1></div><p class=\"text-sm text-content-muted\">Organize your books into custom collections</p></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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -55,7 +55,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span>Restore System</span></button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Restore System</button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -63,12 +63,12 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "New Collection</button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(collections) == 0 { if len(collections) == 0 {
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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"col-span-full flex flex-col items-center justify-center rounded-2xl border border-dashed border-line py-20 text-center\"><div class=\"mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-surface-hover text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -76,56 +76,61 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><h3 class=\"text-lg font-semibold text-content\">No Collections Yet</h3><p class=\"mb-4 mt-1 text-sm text-content-muted\">Create collections to organize your books.</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">Create Your First Collection</button></div>")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
for _, col := range collections { for _, col := range collections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div @click=\"navigateToCollection($el)\" data-href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div @click=\"navigateToCollection($el)\" data-href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 52, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"block\"><div class=\"card p-6 rounded-2xl border-l-4 cursor-pointer\" data-color=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"block\"><div class=\"card cursor-pointer p-5\" style=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color) templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("border-left: 4px solid " + col.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 53, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-color=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 53, Col: 114}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, 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, 11, "\"><div class=\"mb-3 flex items-start justify-between\"><div class=\"text-2xl\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 55, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -133,16 +138,16 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal") templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 57, Col: 67}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn\" aria-label=\"Edit collection\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn h-8 w-8\" title=\"Edit\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -154,16 +159,16 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "") templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 60, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"icon-btn\" aria-label=\"Delete collection\">") 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 h-8 w-8 hover:text-danger\" title=\"Delete\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -171,32 +176,32 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></div><h3 class=\"mb-1 text-base font-semibold text-content\">")
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: 99, 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, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 65, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 63}
}
_, 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></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
@@ -234,25 +239,25 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx) templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil { if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var9 = templ.NopComponent templ_7745c5c3_Var10 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>") 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 83, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
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\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -264,7 +269,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-content-muted transition-colors hover:text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -272,51 +277,67 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<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 }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Back to Collections</a><div class=\"flex items-center gap-4\"><div class=\"flex h-12 w-12 items-center justify-center rounded-xl text-2xl\" style=\"")
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: 132, 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, 25, "</div><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var12, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + collection.Color + "20; color: " + collection.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 97, Col: 164}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 97, Col: 184}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div><h1 class=\"text-2xl font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if !collection.IsSystem { var templ_7745c5c3_Var14 string
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\">") templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 83}
}
_, 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, "</h1><p class=\"text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 69}
}
_, 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, "</p></div></div></div><div class=\"mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex items-center gap-3\"><h2 class=\"text-sm font-semibold uppercase tracking-wide text-content-muted\">Books in this Collection</h2><span id=\"selected-count\" class=\"hidden badge bg-brand/15 text-brand\">0 selected</span></div><div class=\"flex flex-1 justify-end gap-2\"><div class=\"relative w-full max-w-xs\"><div class=\"pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted\">")
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, "</div><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection…\" onkeyup=\"filterCollectionBooks()\" class=\"input pl-9\"></div><button id=\"bulk-remove-btn\" disabled class=\"btn btn-danger disabled:opacity-40 disabled:cursor-not-allowed\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -324,7 +345,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Remove Selected</span></button> <button @click=\"$store.bookPicker.open()\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Remove Selected</button> <button @click=\"$store.bookPicker.open()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -332,190 +353,121 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span>Add Books</span></button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Add Books</button></div></div><div id=\"books-container\" class=\"grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3\">")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(books) == 0 { if len(books) == 0 {
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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div id=\"empty-state\" class=\"col-span-full rounded-2xl border border-dashed border-line py-16 text-center text-sm text-content-muted\">No books in this collection yet.</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
for _, book := range books { for _, book := range books {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card p-4 rounded-2xl collection-book-card\" data-title=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card flex gap-4 p-4\"><div class=\"flex flex-shrink-0 items-center pt-1\"><input type=\"checkbox\" onchange=\"toggleBookForRemoval('{ book.MediaItemID }')\" class=\"h-5 w-5 rounded border-line\"></div><a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" data-author=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"flex min-w-0 flex-1 gap-3\"><div class=\"flex-shrink-0 w-12 sm:w-16\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string if book.CoverImagePath != "" {
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-media-id=\"") var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 138, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" alt=\"Cover\" class=\"aspect-[3/4] w-full rounded object-cover shadow-card\" onerror=\"this.src='/static/placeholder-book.svg'\">")
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: 179, Col: 147}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"><div class=\"flex gap-4\">") } else {
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"aspect-[3/4] w-full rounded object-cover shadow-card\">")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex-1 min-w-0\"><a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"min-w-0 flex-1\"><h3 class=\"line-clamp-2 text-sm font-semibold text-content\">")
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: 194, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 144, Col: 81}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h3>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.Author != "" { if book.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<p class=\"mt-0.5 line-clamp-1 text-xs text-content-muted\">by ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 146, Col: 84}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\"><a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 templ.SafeURL var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("removeBook('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 148, Col: 66}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"mt-2 inline-flex items-center gap-1 text-xs text-content-muted transition-colors hover:text-danger\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.CoverImagePath != "" { templ_7745c5c3_Err = Icon("trash", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<img src=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 string templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Remove</button></div></a></div>")
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: 209, Col: 37}
}
_, 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, 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, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</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-cloak 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 my-8 w-full max-w-6xl overflow-hidden\" style=\"display: none;\"><div class=\"flex items-center justify-between border-b border-line p-5\"><h2 class=\"text-lg font-bold text-content\">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
}
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, 52, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -523,7 +475,46 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</button></div><div class=\"flex flex-wrap items-center gap-3 border-b border-line p-4\"><div class=\"min-w-[12rem] flex-1\"><input type=\"text\" name=\"search\" placeholder=\"Search books…\" class=\"input\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 182, Col: 170}
}
_, 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, 47, "\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"min-w-[10rem] flex-1\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"input\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 185, Col: 168}
}
_, 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, 48, "\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"min-w-[10rem] flex-1\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"input\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/media-items/filtered?show_checkbox=true&collection_id=" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 188, Col: 166}
}
_, 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, 49, "\" 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -531,199 +522,36 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Clear</button><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 max-h-96 grid-cols-2 gap-4 overflow-y-auto p-4 md:grid-cols-4 lg:grid-cols-6\"></div><div class=\"flex items-center justify-between border-t border-line p-4\"><div class=\"text-sm text-content-muted\"><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></body><div id=\"collection-data\" data-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
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 = 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, 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
}
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: 385, 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: 386, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 210, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" style=\"display: none;\"></div></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" data-library-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
return nil var templ_7745c5c3_Var25 string
}) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 210, Col: 81}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var26 string templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" style=\"display: none;\"></div></html>")
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: 397, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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: 401, 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: 401, 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: 403, 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: 406, 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: 411, 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: 418, 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 return nil
}) })
} }
+69 -84
View File
@@ -11,58 +11,45 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<title>Sync Conflicts - Bookhoard</title> <title>Sync Conflicts - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="conflicts" class="theme-{ user.Theme }"> <body x-data="conflicts" class="theme-{ user.Theme }">
@Header(user, "/conflicts") @Header(user, "/conflicts")
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> <div class="mb-8">
<div class="flex justify-between items-center gap-4 flex-wrap"> <div class="flex justify-between items-center">
<div> <div>
<div class="flex items-center gap-3 mb-1"> <h1 class="text-2xl font-bold tracking-tight text-content">Sync Conflicts</h1>
<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);"> <p class="text-content-muted">Resolve conflicts when reading progress differs across devices</p>
@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>
<div class="flex space-x-3"> <div class="flex space-x-3">
<select <select
id="status-filter" id="status-filter"
onchange="filterConflicts()" onchange="filterConflicts()"
class="input" class="input w-auto"
> >
<option value="unresolved">Unresolved Only</option> <option value="unresolved">Unresolved Only</option>
<option value="all">All Conflicts</option> <option value="all">All Conflicts</option>
<option value="resolved">Resolved Only</option> <option value="resolved">Resolved Only</option>
</select> </select>
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button> <button @click="dismissAllResolved()" class="btn btn-secondary">
Dismiss All Resolved
</button>
</div> </div>
</div> </div>
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg"> <div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
<div class="stat-card text-center py-3"> <span class="text-content-muted">Total: <strong class="text-content">{ total }</strong></span>
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div> <span class="text-content-muted">Unresolved: <strong class="text-warning">{ unresolved }</strong></span>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div> <span class="text-content-muted">Resolved: <strong class="text-success">{ total - unresolved }</strong></span>
</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>
<div id="bulk-actions" class="mt-4 hidden"> <div id="bulk-actions" class="mt-4 hidden">
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap"> <div class="card p-4 flex items-center justify-between">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<span class="text-sm font-semibold" style="color: var(--text-primary);"> <span class="text-sm font-semibold text-content">
<span id="selected-count">0</span> conflicts selected <span id="selected-count">0</span> conflicts selected
</span> </span>
<select <select
id="bulk-strategy" id="bulk-strategy"
class="input w-auto" class="input w-auto text-sm"
> >
<option value="most_recent">Most Recent</option> <option value="most_recent">Most Recent</option>
<option value="highest_progress">Highest Progress</option> <option value="highest_progress">Highest Progress</option>
@@ -74,62 +61,60 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
Resolve Selected Resolve Selected
</button> </button>
<button @click="bulkDismiss()" class="btn btn-danger text-sm"> <button @click="bulkDismiss()" class="btn btn-danger text-sm">
@Icon("x-circle", "h-4 w-4")
Dismiss Selected Dismiss Selected
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)"> <div id="loading" class="hidden text-center py-16 text-content-muted">
<div class="loading-spinner mx-auto mb-4"></div> <div class="loading-spinner mx-auto mb-4"></div>
<p>Loading conflicts...</p> <p>Loading conflicts...</p>
</div> </div>
<div id="empty-state" class="hidden card text-center py-16"> <div id="empty-state" class="hidden text-center py-16 text-content-muted">
<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);"> <div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-success">
@Icon("check-circle", "h-7 w-7") @Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
</div> </div>
<div id="conflicts-list" class="space-y-4"> <h3 class="text-xl font-semibold mb-2 text-content">No Conflicts</h3>
<p>Your devices are in sync! No conflicts to resolve.</p>
</div>
<div id="conflicts-list" class="space-y-6">
<div id="select-all-container" class="mb-4 hidden"> <div id="select-all-container" class="mb-4 hidden">
<label class="flex items-center space-x-2 cursor-pointer"> <label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/> <input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span> <span class="text-sm font-semibold text-content">Select All</span>
</label> </label>
</div> </div>
if len(conflicts) == 0 { if len(conflicts) == 0 {
<div class="card text-center py-16"> <div class="text-center py-16 text-content-muted">
<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);"> <div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-success">
@Icon("check-circle", "h-7 w-7") @Icon("check-circle", "h-7 w-7")
</span> </div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3> <h3 class="text-xl font-semibold mb-2 text-content">No Conflicts</h3>
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p> <p>Your devices are in sync! No conflicts to resolve.</p>
</div> </div>
} }
for _, conflict := range conflicts { for _, conflict := range conflicts {
<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="conflict-item card p-6 border-l-4 border-l-warning" data-conflict-id="{ conflict.ID }">
<div class="flex justify-between items-start mb-4 gap-4"> <div class="flex justify-between items-start mb-4">
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-4">
<input <input
type="checkbox" type="checkbox"
class="conflict-checkbox w-5 h-5 rounded mt-1" class="conflict-checkbox w-5 h-5 rounded mt-1"
data-conflict-id={ conflict.ID } data-conflict-id="{ conflict.ID }"
onchange="updateBulkActions()" onchange="updateBulkActions()"
/> />
<div class="flex items-start gap-3">
<span style="color: var(--status-warning);">
@Icon("alert", "h-5 w-5")
</span>
<div> <div>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3> <h3 class="text-lg font-semibold text-content">{ conflict.MediaItemTitle }</h3>
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p> <p class="text-sm text-content-muted">Type: { conflict.ConflictType }</p>
</div> </div>
</div> </div>
<button @click="showResolveModal('{ conflict.ID }')" class="btn btn-primary">
Resolve
</button>
</div> </div>
<button @click="showResolveModal('{ conflict.ID }')" class="btn btn-primary">Resolve</button> <div class="text-sm text-content-muted">
</div>
<div class="text-sm" style="color: var(--text-secondary)">
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) } Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
if conflict.ResolvedBy != "" { if conflict.ResolvedBy != "" {
| Resolved by: { conflict.ResolvedBy } | Resolved by: { conflict.ResolvedBy }
@@ -138,12 +123,12 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
</div> </div>
} }
</div> </div>
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);"> <div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-4xl mx-4 my-8">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<div> <div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2> <h2 class="text-2xl font-bold tracking-tight text-content">Resolve Conflict</h2>
<p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p> <p id="conflict-book-title" class="text-sm text-content-muted">Book Title</p>
</div> </div>
<button @click="hideResolveModal()" class="icon-btn" aria-label="Close"> <button @click="hideResolveModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
@@ -152,43 +137,43 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<form id="conflict-resolution-form" @submit="handleResolveSubmit($event)"> <form id="conflict-resolution-form" @submit="handleResolveSubmit($event)">
<input type="hidden" id="conflict-id"/> <input type="hidden" id="conflict-id"/>
<div id="conflict-details" class="mb-6"></div> <div id="conflict-details" class="mb-6"></div>
<div class="card p-4 mb-6"> <div class="card p-4 mb-6 bg-surface">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3> <h3 class="font-semibold mb-4 text-content">Resolution Options</h3>
<div class="space-y-3 mb-6"> <div class="space-y-3 mb-6">
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);"> <label class="flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover">
<input type="radio" name="winner" value="koreader" class="w-4 h-4" required/> <input type="radio" name="winner" value="koreader" class="mr-3" required/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span> <span class="font-semibold text-content">Keep KOReader Progress</span>
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p> <p class="text-xs text-content-muted">Use progress from your KOReader device</p>
</div> </div>
</label> </label>
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);"> <label class="flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover">
<input type="radio" name="winner" value="kobo" class="w-4 h-4"/> <input type="radio" name="winner" value="kobo" class="mr-3"/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span> <span class="font-semibold text-content">Keep Kobo Progress</span>
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p> <p class="text-xs text-content-muted">Use progress from your Kobo device</p>
</div> </div>
</label> </label>
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);"> <label class="flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover">
<input type="radio" name="winner" value="web" class="w-4 h-4"/> <input type="radio" name="winner" value="web" class="mr-3"/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span> <span class="font-semibold text-content">Keep Web Progress</span>
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p> <p class="text-xs text-content-muted">Use progress from the web interface</p>
</div> </div>
</label> </label>
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);"> <label class="flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover">
<input type="radio" name="winner" value="manual" class="w-4 h-4"/> <input type="radio" name="winner" value="manual" class="mr-3"/>
<div class="flex-1"> <div class="flex-1">
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span> <span class="font-semibold text-content">Manual Override</span>
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p> <p class="text-xs text-content-muted">Specify a custom progress value</p>
</div> </div>
</label> </label>
</div> </div>
<div id="manual-override" class="hidden mb-4 p-4 rounded-lg" style="background-color: var(--bg-primary); border: 1px solid var(--border);"> <div id="manual-override" class="hidden mb-4 p-4 border border-line rounded-lg bg-surface-raised">
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4> <h4 class="font-semibold mb-3 text-content">Manual Progress</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Percentage (0-100)</label>
<input <input
type="number" type="number"
id="manual-percentage" id="manual-percentage"
@@ -199,7 +184,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Page Number</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Page Number</label>
<input <input
type="number" type="number"
id="manual-page" id="manual-page"
@@ -208,7 +193,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">EPUB CFI</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">EPUB CFI</label>
<input <input
type="text" type="text"
id="manual-epubcfi" id="manual-epubcfi"
@@ -216,7 +201,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Chapter</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Chapter</label>
<input <input
type="number" type="number"
id="manual-chapter" id="manual-chapter"
@@ -229,11 +214,11 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
<div class="mb-6"> <div class="mb-6">
<label class="flex items-center space-x-3 cursor-pointer"> <label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"/> <input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"/>
<span style="color: var(--text-primary);">Apply this resolution to all future conflicts</span> <span class="text-content">Apply this resolution to all future conflicts</span>
</label> </label>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Resolution Reason (optional)</label>
<input <input
type="text" type="text"
id="resolution-reason" id="resolution-reason"
@@ -242,7 +227,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
/> />
</div> </div>
</div> </div>
<div class="flex justify-end space-x-3 flex-wrap"> <div class="flex justify-end space-x-3">
<button type="button" @click="hideResolveModal()" class="btn btn-secondary">Cancel</button> <button type="button" @click="hideResolveModal()" class="btn btn-secondary">Cancel</button>
<button type="button" @click="deleteConflict()" class="btn btn-danger">Dismiss</button> <button type="button" @click="deleteConflict()" class="btn btn-danger">Dismiss</button>
<button type="submit" class="btn btn-primary">Resolve Conflict</button> <button type="submit" class="btn btn-primary">Resolve Conflict</button>
+43 -77
View File
@@ -31,7 +31,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"conflicts\" class=\"theme-{ user.Theme }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Sync Conflicts - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"conflicts\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -39,54 +39,46 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);\">") 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-2xl font-bold tracking-tight text-content\">Sync Conflicts</h1><p class=\"text-content-muted\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" onchange=\"filterConflicts()\" class=\"input w-auto\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 flex space-x-6 text-sm\"><span class=\"text-content-muted\">Total: <strong class=\"text-content\">")
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)\">Sync Conflicts</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve conflicts when reading progress differs across devices</p></div><div class=\"flex space-x-3\"><select id=\"status-filter\" onchange=\"filterConflicts()\" class=\"input\"><option value=\"unresolved\">Unresolved Only</option> <option value=\"all\">All Conflicts</option> <option value=\"resolved\">Resolved Only</option></select> <button @click=\"dismissAllResolved()\" class=\"btn btn-secondary\">Dismiss All Resolved</button></div></div><div id=\"stats-bar\" class=\"mt-4 grid grid-cols-3 gap-4 max-w-lg\"><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 45, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 40, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</strong></span> <span class=\"text-content-muted\">Unresolved: <strong class=\"text-warning\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 41, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</strong></span> <span class=\"text-content-muted\">Resolved: <strong class=\"text-success\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 53, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 42, Col: 98}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div></div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"bulkResolve()\" class=\"btn btn-primary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong></span></div><div id=\"bulk-actions\" class=\"mt-4 hidden\"><div class=\"card p-4 flex items-center justify-between\"><div class=\"flex items-center space-x-4\"><span class=\"text-sm font-semibold text-content\"><span id=\"selected-count\">0</span> conflicts selected</span> <select id=\"bulk-strategy\" class=\"input w-auto text-sm\"><option value=\"most_recent\">Most Recent</option> <option value=\"highest_progress\">Highest Progress</option></select></div><div class=\"flex space-x-2\"><button @click=\"bulkResolve()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -94,7 +86,15 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Resolve Selected</button> <button @click=\"bulkDismiss()\" class=\"btn btn-danger text-sm\">Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Resolve Selected</button> <button @click=\"bulkDismiss()\" class=\"btn btn-danger text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("x-circle", "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, "Dismiss Selected</button></div></div></div></div><div id=\"loading\" class=\"hidden text-center py-16 text-content-muted\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading conflicts...</p></div><div id=\"empty-state\" class=\"hidden text-center py-16 text-content-muted\"><div class=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-success\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -102,12 +102,12 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-4\"><div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" onchange=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Select All</span></label></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><h3 class=\"text-xl font-semibold mb-2 text-content\">No Conflicts</h3><p>Your devices are in sync! No conflicts to resolve.</p></div><div id=\"conflicts-list\" class=\"space-y-6\"><div id=\"select-all-container\" class=\"mb-4 hidden\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"checkbox\" id=\"select-all-conflicts\" class=\"w-5 h-5 rounded\" onchange=\"toggleAllConflicts()\"> <span class=\"text-sm font-semibold text-content\">Select All</span></label></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(conflicts) == 0 { if len(conflicts) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"text-center py-16 text-content-muted\"><div class=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-success\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -115,110 +115,76 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Conflicts</h3><p style=\"color: var(--text-secondary)\">Your devices are in sync! No conflicts to resolve.</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><h3 class=\"text-xl font-semibold mb-2 text-content\">No Conflicts</h3><p>Your devices are in sync! No conflicts to resolve.</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
for _, conflict := range conflicts { for _, conflict := range conflicts {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"conflict-item card p-6\" data-conflict-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"conflict-item card p-6 border-l-4 border-l-warning\" data-conflict-id=\"{ conflict.ID }\"><div class=\"flex justify-between items-start mb-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"{ conflict.ID }\" onchange=\"updateBulkActions()\"><div><h3 class=\"text-lg font-semibold text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 111, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 109, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\"><div class=\"flex justify-between items-start mb-4 gap-4\"><div class=\"flex items-start space-x-4\"><input type=\"checkbox\" class=\"conflict-checkbox w-5 h-5 rounded mt-1\" data-conflict-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h3><p class=\"text-sm text-content-muted\">Type: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 117, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 110, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" onchange=\"updateBulkActions()\"><div class=\"flex items-start gap-3\"><span style=\"color: var(--status-warning);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div></div><button @click=\"showResolveModal('{ conflict.ID }')\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm text-content-muted\">Created: ")
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, 14, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 118, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Type: ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if conflict.ResolvedBy != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "| Resolved by: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 126, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 120, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p></div></div></div><button @click=\"showResolveModal('{ conflict.ID }')\" class=\"btn btn-primary\">Resolve</button></div><div class=\"text-sm\" style=\"color: var(--text-secondary)\">Created: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 133, Col: 70}
}
_, 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, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if conflict.ResolvedBy != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "| Resolved by: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 135, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div id=\"conflict-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-4xl mx-4 my-8\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-2xl font-bold tracking-tight text-content\">Resolve Conflict</h2><p id=\"conflict-book-title\" class=\"text-sm text-content-muted\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div id=\"conflict-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-4xl my-8\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Resolve Conflict</h2><p class=\"text-sm\" id=\"conflict-book-title\" style=\"color: var(--text-secondary);\">Book Title</p></div><button @click=\"hideResolveModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -226,7 +192,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6\"><h3 class=\"font-semibold mb-4\" style=\"color: var(--text-primary)\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"w-4 h-4\" required><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep KOReader Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Kobo Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Keep Web Progress</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Use progress from the web interface</p></div></label> <label class=\"flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors\" style=\"border-color: var(--border);\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"w-4 h-4\"><div class=\"flex-1\"><span class=\"font-semibold\" style=\"color: var(--text-primary);\">Manual Override</span><p class=\"text-xs\" style=\"color: var(--text-secondary);\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 rounded-lg\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary);\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary);\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3 flex-wrap\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button></div><form id=\"conflict-resolution-form\" @submit=\"handleResolveSubmit($event)\"><input type=\"hidden\" id=\"conflict-id\"><div id=\"conflict-details\" class=\"mb-6\"></div><div class=\"card p-4 mb-6 bg-surface\"><h3 class=\"font-semibold mb-4 text-content\">Resolution Options</h3><div class=\"space-y-3 mb-6\"><label class=\"flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover\"><input type=\"radio\" name=\"winner\" value=\"koreader\" class=\"mr-3\" required><div class=\"flex-1\"><span class=\"font-semibold text-content\">Keep KOReader Progress</span><p class=\"text-xs text-content-muted\">Use progress from your KOReader device</p></div></label> <label class=\"flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover\"><input type=\"radio\" name=\"winner\" value=\"kobo\" class=\"mr-3\"><div class=\"flex-1\"><span class=\"font-semibold text-content\">Keep Kobo Progress</span><p class=\"text-xs text-content-muted\">Use progress from your Kobo device</p></div></label> <label class=\"flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover\"><input type=\"radio\" name=\"winner\" value=\"web\" class=\"mr-3\"><div class=\"flex-1\"><span class=\"font-semibold text-content\">Keep Web Progress</span><p class=\"text-xs text-content-muted\">Use progress from the web interface</p></div></label> <label class=\"flex items-center p-3 border border-line rounded-lg cursor-pointer hover:bg-surface-hover\"><input type=\"radio\" name=\"winner\" value=\"manual\" class=\"mr-3\"><div class=\"flex-1\"><span class=\"font-semibold text-content\">Manual Override</span><p class=\"text-xs text-content-muted\">Specify a custom progress value</p></div></label></div><div id=\"manual-override\" class=\"hidden mb-4 p-4 border border-line rounded-lg bg-surface-raised\"><h4 class=\"font-semibold mb-3 text-content\">Manual Progress</h4><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Percentage (0-100)</label> <input type=\"number\" id=\"manual-percentage\" min=\"0\" max=\"100\" step=\"0.01\" class=\"input\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Page Number</label> <input type=\"number\" id=\"manual-page\" min=\"1\" class=\"input\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">EPUB CFI</label> <input type=\"text\" id=\"manual-epubcfi\" class=\"input\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Chapter</label> <input type=\"number\" id=\"manual-chapter\" min=\"1\" class=\"input\"></div></div></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"apply-to-all\" class=\"w-5 h-5 rounded\"> <span class=\"text-content\">Apply this resolution to all future conflicts</span></label></div><div class=\"mb-6\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Resolution Reason (optional)</label> <input type=\"text\" id=\"resolution-reason\" class=\"input\" placeholder=\"e.g., Device was offline, using most recent progress\"></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideResolveModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"deleteConflict()\" class=\"btn btn-danger\">Dismiss</button> <button type=\"submit\" class=\"btn btn-primary\">Resolve Conflict</button></div></form></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -234,7 +200,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+33 -51
View File
@@ -9,29 +9,19 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
<title>Create Custom Section - Bookhoard</title> <title>Create Custom Section - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }">
@Header(user, "/custom-section") @Header(user, "/custom-section")
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"> <main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-8"> <h1 class="text-2xl font-bold tracking-tight text-content mb-2">Create Custom Section</h1>
<div class="flex items-center gap-3 mb-1"> <p class="mb-6 text-content-muted">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
<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"> <form id="custom-section-form" class="space-y-6">
<div class="card p-6"> <!-- Section Details -->
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)"> <div class="p-4 rounded-lg bg-surface-raised">
@Icon("info", "h-5 w-5") <h2 class="text-xl font-semibold mb-4 text-content">Section Details</h2>
Section Details
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label for="section-name" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Name *</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Name *</label>
<input <input
type="text" type="text"
id="section-name" id="section-name"
@@ -41,7 +31,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
/> />
</div> </div>
<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> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Icon (emoji)</label>
<input <input
type="text" type="text"
id="section-icon" id="section-icon"
@@ -53,7 +43,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
</div> </div>
</div> </div>
<div class="mt-4"> <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> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Description</label>
<textarea <textarea
id="section-description" id="section-description"
name="description" name="description"
@@ -62,26 +52,24 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
></textarea> ></textarea>
</div> </div>
<div class="mt-4"> <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> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Library *</label>
<select <select
id="section-library" id="section-library"
name="library_id" name="library_id"
required required
class="input" class="input"
> >
<option value="">Select a library</option> <option value="">Select a library...</option>
for _, lib := range libraries { for _, lib := range libraries {
<option value={ lib.ID }>{ lib.Name }</option> <option value={ lib.ID }>{ lib.Name }</option>
} }
</select> </select>
</div> </div>
</div> </div>
<div class="card p-6"> <!-- Filter Rules -->
<div class="p-4 rounded-lg bg-surface-raised">
<div class="flex items-center justify-between mb-4"> <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)"> <h2 class="text-xl font-semibold text-content">Filter Rules</h2>
@Icon("filter", "h-5 w-5")
Filter Rules
</h2>
<button <button
type="button" type="button"
id="add-rule-btn" id="add-rule-btn"
@@ -91,12 +79,12 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
Add Rule Add Rule
</button> </button>
</div> </div>
<p class="text-sm mb-4" style="color: var(--text-secondary)"> <p class="text-sm mb-4 text-content-muted">
Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule. Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
</p> </p>
<div id="rules-container" class="space-y-3"></div> <div id="rules-container" class="space-y-3"></div>
<div class="mt-4 flex items-center gap-2"> <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> <label class="text-sm font-medium text-content-muted">Match:</label>
<select <select
id="match-type" id="match-type"
name="match_type" name="match_type"
@@ -107,23 +95,21 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
</select> </select>
</div> </div>
</div> </div>
<div class="card p-6"> <!-- Manual Book Selection -->
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)"> <div class="p-4 rounded-lg bg-surface-raised">
@Icon("book", "h-5 w-5") <h2 class="text-xl font-semibold mb-4 text-content">Manual Book Selection</h2>
Manual Book Selection <p class="text-sm mb-4 text-content-muted">
</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. Add specific books to this section. Use the search to find and select multiple books.
</p> </p>
<div class="mb-4"> <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> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Search Books</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="text" type="text"
id="book-search" id="book-search"
name="book_search" name="book_search"
class="input flex-1" class="input flex-1"
placeholder="Search by title or author" placeholder="Search by title or author..."
autocomplete="off" autocomplete="off"
/> />
<button <button
@@ -138,30 +124,26 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
</div> </div>
<div <div
id="search-results" id="search-results"
class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2" class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto bg-surface"
style="background-color: var(--bg-primary);"
></div> ></div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label> <label class="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">Selected Books</label>
<div <div
id="selected-books" id="selected-books"
class="min-h-[60px] p-3 rounded-xl border-2 border-dashed" class="min-h-[60px] p-3 rounded-lg border-2 border-dashed border-line bg-surface"
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> <p class="text-sm text-center text-content-muted">No books selected</p>
</div> </div>
</div> </div>
</div> </div>
<div class="card p-6"> <!-- Live Preview -->
<div class="p-4 rounded-lg bg-surface-raised">
<div class="flex items-center justify-between mb-4"> <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)"> <h2 class="text-xl font-semibold text-content">Live Preview</h2>
@Icon("play", "h-5 w-5")
Live Preview
</h2>
<button <button
type="button" type="button"
id="preview-btn" id="preview-btn"
class="btn btn-secondary text-sm" class="btn btn-primary"
> >
@Icon("refresh", "h-4 w-4") @Icon("refresh", "h-4 w-4")
Refresh Preview Refresh Preview
@@ -169,14 +151,14 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
</div> </div>
<div <div
id="preview-container" id="preview-container"
class="card p-4" class="p-4 rounded-lg bg-surface min-h-[200px]"
style="background-color: var(--bg-primary); min-height: 200px;"
> >
<p class="text-center text-sm" style="color: var(--text-secondary);"> <p class="text-center text-content-muted">
Add filter rules or select books to see a preview of your custom section. Add filter rules or select books to see a preview of your custom section.
</p> </p>
</div> </div>
</div> </div>
<!-- Form Actions -->
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
type="button" type="button"
+13 -53
View File
@@ -29,7 +29,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Create Custom Section - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">") 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>Create Custom Section - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -37,67 +37,43 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><h1 class=\"text-2xl font-bold tracking-tight text-content mb-2\">Create Custom Section</h1><p class=\"mb-6 text-content-muted\">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 bg-surface-raised\"><h2 class=\"text-xl font-semibold mb-4 text-content\">Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"input\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"input\"></textarea></div><div class=\"mt-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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
}
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, lib := range libraries { for _, lib := range libraries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 64, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 64, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</select></div></div><!-- Filter Rules --><div class=\"p-4 rounded-lg bg-surface-raised\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold text-content\">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("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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -105,15 +81,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "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)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Add Rule</button></div><p class=\"text-sm mb-4 text-content-muted\">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 text-content-muted\">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><!-- Manual Book Selection --><div class=\"p-4 rounded-lg bg-surface-raised\"><h2 class=\"text-xl font-semibold mb-4 text-content\">Manual Book Selection</h2><p class=\"text-sm mb-4 text-content-muted\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">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("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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -121,15 +89,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "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)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto bg-surface\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-lg border-2 border-dashed border-line bg-surface\"><p class=\"text-sm text-center text-content-muted\">No books selected</p></div></div></div><!-- Live Preview --><div class=\"p-4 rounded-lg bg-surface-raised\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold text-content\">Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"btn btn-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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -137,7 +97,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Refresh Preview</button></div><div id=\"preview-container\" class=\"p-4 rounded-lg bg-surface min-h-[200px]\"><p class=\"text-center text-content-muted\">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=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -145,7 +105,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Save Section</button></div></form></main>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Save Section</button></div></form></main>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -153,7 +113,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+70 -96
View File
@@ -14,19 +14,26 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
<title>Dashboard - Bookhoard</title> <title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }"> <body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
@Header(user, "/dashboard") @Header(user, "/dashboard")
@LibrarySwitcher(libData, currentLibraryID, DashboardActions()) @LibrarySwitcher(libData, currentLibraryID, DashboardActions())
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8"> <main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> if len(sections) == 0 {
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1> <div class="card mx-auto max-w-md p-10 text-center">
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p> <div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand">
@Icon("book-open", "h-7 w-7")
</div> </div>
<h2 class="text-lg font-semibold text-content">Your library is empty</h2>
<p class="mt-1 text-sm text-content-muted">Once books are added to your libraries, collections will appear here.</p>
</div>
} else {
<div class="space-y-10">
for _, section := range sections { for _, section := range sections {
@CollectionCarousel(section) @CollectionCarousel(section)
} }
</div>
}
</main> </main>
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection) @DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
@@ -35,42 +42,40 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
} }
templ CollectionCarousel(section handlers.SectionData) { templ CollectionCarousel(section handlers.SectionData) {
<div <section
class="dashboard-collection mb-10" class="dashboard-collection"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-is-system={ section.IsSystem } data-is-system={ section.IsSystem }
> >
<div class="flex items-end justify-between mb-4"> <div class="mb-4 flex items-end justify-between gap-4">
<div class="flex items-center gap-3"> <div class="min-w-0">
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted);">{ section.Icon }</span> <h2 class="flex items-center gap-2 text-lg font-bold tracking-tight text-content">
<div> if section.Icon != "" {
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">{ section.Title }</h2> <span class="text-base">{ section.Icon }</span>
}
{ section.Title }
</h2>
if section.Description != "" { if section.Description != "" {
<p class="text-xs" style="color: var(--text-secondary)">{ section.Description }</p> <p class="mt-0.5 text-sm text-content-muted">{ section.Description }</p>
} }
</div> </div>
</div>
if section.ViewAllURL != "" { if section.ViewAllURL != "" {
<a <a href={ section.ViewAllURL } class="flex items-center gap-1 text-sm font-medium text-content-muted transition-colors hover:text-brand">
href={ section.ViewAllURL }
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") @Icon("arrow-right", "h-4 w-4")
</a> </a>
} }
</div> </div>
<div class="carousel-container relative group"> <div class="carousel-container relative group">
<button <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" class="carousel-nav-left absolute left-0 top-0 z-10 flex h-full w-12 items-center justify-start bg-gradient-to-r from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100"
data-action="scroll-carousel" data-action="scroll-carousel"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-direction="-1" data-direction="-1"
aria-label="Scroll left" aria-label="Scroll left"
style="background: linear-gradient(to right, var(--bg-primary), transparent);"
> >
@Icon("chevron-left", "h-7 w-7") @Icon("chevron-left", "h-6 w-6 text-content")
</button> </button>
<div <div
id={ "carousel-track-" + section.ID } id={ "carousel-track-" + section.ID }
@@ -78,154 +83,123 @@ templ CollectionCarousel(section handlers.SectionData) {
style="scrollbar-width: none; -ms-overflow-style: none;" style="scrollbar-width: none; -ms-overflow-style: none;"
> >
for _, item := range section.Items { for _, item := range section.Items {
<div class="flex-shrink-0 w-36 sm:w-40 snap-start" data-media-item-id={ item.MediaItemID }>
@BookCard(item) @BookCard(item)
</div>
} }
if len(section.Items) == 0 { if len(section.Items) == 0 {
<div class="flex flex-col items-center justify-center text-center py-12 w-full gap-2" style="color: var(--text-secondary);"> <div class="w-full py-10 text-center text-sm text-content-muted">
@Icon("book", "h-8 w-8 opacity-50") No items in this collection
<p class="text-sm">No items in this collection</p>
</div> </div>
} }
</div> </div>
<button <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" class="carousel-nav-right absolute right-0 top-0 z-10 flex h-full w-12 items-center justify-end bg-gradient-to-l from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100"
data-action="scroll-carousel" data-action="scroll-carousel"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-direction="1" data-direction="1"
aria-label="Scroll right" aria-label="Scroll right"
style="background: linear-gradient(to left, var(--bg-primary), transparent);"
> >
@Icon("chevron-right", "h-7 w-7") @Icon("chevron-right", "h-6 w-6 text-content")
</button> </button>
</div> </div>
</div> </section>
} }
// BookCard is the single, canonical cover-forward card used across the
// dashboard, bookshelf, series and collections. The metadata block sits on a
// real surface so cards always read as cohesive objects (previously the text
// floated with no background when wood-paneling was off).
templ BookCard(item handlers.BookInfo) { templ BookCard(item handlers.BookInfo) {
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer"> <a href={ "/media/" + item.MediaItemID } title={ item.Title } class="block w-36 flex-shrink-0 snap-start">
<a href={ "/media/" + item.MediaItemID } class="block h-full"> <div class="book-card" tabindex="0" role="button" aria-label={ "View " + item.Title }>
<div <div class="aspect-[2/3] overflow-hidden bg-surface-hover">
class="book-card-cover aspect-[2/3] overflow-hidden"
style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);"
>
if item.CoverImagePath != "" { if item.CoverImagePath != "" {
<img <img
src={ item.CoverImagePath } src={ item.CoverImagePath }
alt={ item.Title } alt={ item.Title }
class="w-full h-full object-cover" class="h-full w-full object-cover"
loading="lazy" loading="lazy"
onerror="this.src='/static/placeholder-book.svg'" onerror="this.src='/static/placeholder-book.svg'"
/> />
} else { } else {
<img <img src="/static/placeholder-book.svg" alt={ item.Title } class="h-full w-full object-cover"/>
src="/static/placeholder-book.svg"
alt={ item.Title }
class="w-full h-full object-cover"
/>
} }
</div> </div>
<div class="book-card-meta"> <div class="book-card-meta">
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }> <h3 class="line-clamp-2 text-sm font-semibold leading-snug text-content" title={ item.Title }>{ item.Title }</h3>
{ item.Title }
</h3>
if item.Author != "" { if item.Author != "" {
<p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }> <p class="mt-0.5 line-clamp-1 text-xs text-content-muted" title={ item.Author }>{ item.Author }</p>
{ 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> </div>
</div> </div>
</a>
} }
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) { templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
<div <div
id="dashboard-settings-modal" id="dashboard-settings-modal"
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);" 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="card w-full max-w-2xl p-6">
<div class="flex justify-between items-center mb-6"> <div class="mb-6 flex items-center justify-between">
<div> <div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2> <h2 class="text-lg font-bold text-content">Customize Dashboard</h2>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p> <p class="mt-0.5 text-sm text-content-muted">Drag to reorder, toggle to show or hide.</p>
</div> </div>
<button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close"> <button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<div id="collection-list" class="space-y-2 mb-6">
<div id="collection-list" class="mb-6 space-y-2">
for _, section := range sections { for _, section := range sections {
<div <div
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card" class="flex items-center justify-between gap-3 rounded-xl border border-line bg-surface px-3 py-2.5 cursor-move select-none"
data-collection-id={ section.ID } data-collection-id={ section.ID }
data-is-system={ fmt.Sprintf("%v", section.IsSystem) } data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
draggable="true" draggable="true"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 min-w-0">
@Icon("grip", "h-5 w-5 shrink-0") @Icon("grip", "h-4 w-4 text-content-muted")
<span class="text-xl">{ section.Icon }</span> if section.Icon != "" {
<div class="flex items-center gap-2"> <span class="text-base">{ section.Icon }</span>
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
if section.IsSystem {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
} }
<div class="min-w-0">
<span class="block truncate text-sm font-medium text-content">{ section.Title }</span>
</div> </div>
if section.IsSystem {
<span class="badge bg-brand/15 text-brand">System</span>
}
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
if section.IsSystem { if section.IsSystem {
<button <button
data-action="restore-system-collection" data-action="restore-system-collection"
data-collection-name={ section.ID } data-collection-name={ section.ID }
class="btn btn-secondary py-1 px-2.5 text-xs" class="btn btn-ghost px-2 py-1 text-xs"
title="Restore { section.Title } to defaults" title="Restore"
> >
Restore Restore
</button> </button>
} }
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex cursor-pointer items-center">
<input <input
type="checkbox" type="checkbox"
class="sr-only peer" class="peer sr-only"
if !ContainsString(hiddenCollections, section.ID) { if !ContainsString(hiddenCollections, section.ID) {
checked checked
} }
/> />
<div <div class="h-6 w-11 rounded-full bg-surface-hover transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-content-muted after:transition-all peer-checked:bg-brand peer-checked:after:translate-x-full peer-checked:after:bg-white peer-focus-visible:ring-2 peer-focus-visible:ring-brand/50"></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> </label>
</div> </div>
</div> </div>
} }
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)"> <label class="mb-2 block text-sm font-medium text-content-muted">
Items per Collection: <span id="items-count-display" class="font-bold" style="color: var(--text-primary)">{ itemsPerSection }</span> Items per Collection: <span id="items-count-display" class="font-bold text-content">{ itemsPerSection }</span>
</label> </label>
<input <input
type="range" type="range"
@@ -233,12 +207,12 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
max="50" max="50"
step="5" step="5"
value={ itemsPerSection } value={ itemsPerSection }
class="w-full h-2 rounded-lg appearance-none cursor-pointer" class="h-2 w-full cursor-pointer appearance-none rounded-full bg-surface-hover"
style="background-color: var(--border-strong);"
data-input-action="update-items-count" data-input-action="update-items-count"
target="items-count-display" target="items-count-display"
/> />
</div> </div>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</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> <button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
+243 -290
View File
@@ -34,7 +34,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"dashboard\" x-init=\"initDashboard()\" class=\"theme-{ user.Theme }\">") 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>Dashboard - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"dashboard\" x-init=\"initDashboard()\" class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -46,7 +46,25 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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>") 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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(sections) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"card mx-auto max-w-md p-10 text-center\"><div class=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "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, 4, "</div><h2 class=\"text-lg font-semibold text-content\">Your library is empty</h2><p class=\"mt-1 text-sm text-content-muted\">Once books are added to your libraries, collections will appear here.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"space-y-10\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -56,7 +74,12 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</main>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -68,7 +91,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -97,100 +120,106 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
templ_7745c5c3_Var2 = templ.NopComponent templ_7745c5c3_Var2 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-10\" data-collection-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<section class=\"dashboard-collection\" data-collection-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-is-system=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-is-system=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 41, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"mb-4 flex items-end justify-between gap-4\"><div class=\"min-w-0\"><h2 class=\"flex items-center gap-2 text-lg font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.Icon != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"text-base\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 130} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
}
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 100} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 56, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if section.Description != "" { if section.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-xs\" style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"mt-0.5 text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 59, 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_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if section.ViewAllURL != "" { if section.ViewAllURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 templ.SafeURL var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"flex items-center gap-1 text-sm font-medium text-content-muted transition-colors hover:text-brand\">View All")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -198,112 +227,83 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><div 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-0 z-10 flex h-full w-12 items-center justify-start bg-gradient-to-r from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100\" data-action=\"scroll-carousel\" data-collection-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 74, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-direction=\"-1\" aria-label=\"Scroll left\" style=\"background: linear-gradient(to right, var(--bg-primary), transparent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-direction=\"-1\" aria-label=\"Scroll left\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("chevron-left", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("chevron-left", "h-6 w-6 text-content").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button><div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</button><div id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
if templ_7745c5c3_Err != nil { 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: 81, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"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;\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, item := range section.Items { 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\" data-media-item-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 92}
}
_, 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, 21, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = BookCard(item).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = BookCard(item).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} }
if len(section.Items) == 0 { if len(section.Items) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"flex flex-col items-center justify-center text-center py-12 w-full gap-2\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"w-full py-10 text-center text-sm text-content-muted\">No items in this collection</div>")
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, 24, "<p class=\"text-sm\">No items in this collection</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><button class=\"carousel-nav-right absolute right-0 top-0 z-10 flex h-full w-12 items-center justify-end bg-gradient-to-l from-surface to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100\" data-action=\"scroll-carousel\" data-collection-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-direction=\"1\" aria-label=\"Scroll right\" style=\"background: linear-gradient(to left, var(--bg-primary), transparent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" data-direction=\"1\" aria-label=\"Scroll right\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("chevron-right", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("chevron-right", "h-6 w-6 text-content").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</button></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</button></div></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -311,6 +311,10 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
}) })
} }
// BookCard is the single, canonical cover-forward card used across the
// dashboard, bookshelf, series and collections. The metadata block sits on a
// real surface so cards always read as cohesive objects (previously the text
// floated with no background when wood-paneling was off).
func BookCard(item handlers.BookInfo) templ.Component { func BookCard(item handlers.BookInfo) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -327,119 +331,114 @@ func BookCard(item handlers.BookInfo) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var13 := templ.GetChildren(ctx) templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var13 == nil { if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var13 = templ.NopComponent templ_7745c5c3_Var12 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer\"><a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 templ.SafeURL var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 112, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" 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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if item.CoverImagePath != "" { var templ_7745c5c3_Var14 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<img src=\"") 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: 112, 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, 31, "\" class=\"block w-36 flex-shrink-0 snap-start\"><div class=\"book-card\" tabindex=\"0\" role=\"button\" aria-label=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("View " + item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 113, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" alt=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"><div class=\"aspect-[2/3] overflow-hidden bg-surface-hover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<img src=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 116, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 117, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<img src=\"/static/placeholder-book.svg\" alt=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 118, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"w-full h-full object-cover\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"h-full w-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div><div class=\"book-card-meta\"><h3 class=\"font-semibold text-sm leading-snug line-clamp-2\" style=\"color: var(--text-primary)\" title=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<img src=\"/static/placeholder-book.svg\" alt=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"h-full w-full object-cover\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"book-card-meta\"><h3 class=\"line-clamp-2 text-sm font-semibold leading-snug text-content\" title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 128, Col: 95}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, 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, 37, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<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_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -447,104 +446,52 @@ func BookCard(item handlers.BookInfo) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 128, Col: 110}
}
_, 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, 40, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"mt-0.5 line-clamp-1 text-xs text-content-muted\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 19} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</p>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} var templ_7745c5c3_Var22 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></a><div class=\"book-card-action\">") templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 98}
}
if item.HasConflict {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, 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: 143, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"book-card-action-btn\" aria-label=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, 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: 145, Col: 63}
}
_, 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, 44, "\" 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, 45, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, 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: 152, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" class=\"book-card-action-btn\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, 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: 154, Col: 38}
}
_, 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, 48, "\" 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, 49, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div></a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -568,12 +515,12 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var26 := templ.GetChildren(ctx) templ_7745c5c3_Var23 := templ.GetChildren(ctx)
if templ_7745c5c3_Var26 == nil { if templ_7745c5c3_Var23 == nil {
templ_7745c5c3_Var26 = templ.NopComponent templ_7745c5c3_Var23 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div id=\"dashboard-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 w-full max-w-2xl p-6\"><div class=\"mb-6 flex items-center justify-between\"><div><h2 class=\"text-lg font-bold text-content\">Customize Dashboard</h2><p class=\"mt-0.5 text-sm text-content-muted\">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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -581,146 +528,152 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><div id=\"collection-list\" class=\"space-y-2 mb-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</button></div><div id=\"collection-list\" class=\"mb-6 space-y-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, section := range sections { for _, section := range sections {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div class=\"collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"flex items-center justify-between gap-3 rounded-xl border border-line bg-surface px-3 py-2.5 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: 158, 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, 48, "\" 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: 159, 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, 49, "\" draggable=\"true\"><div class=\"flex items-center gap-3 min-w-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("grip", "h-4 w-4 text-content-muted").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.Icon != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"text-base\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 165, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"min-w-0\"><span class=\"block truncate text-sm font-medium text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 168, Col: 85}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" data-is-system=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"badge bg-brand/15 text-brand\">System</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</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, 56, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem)) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" draggable=\"true\"><div class=\"flex items-center gap-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" class=\"btn btn-ghost px-2 py-1 text-xs\" title=\"Restore\">Restore</button> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("grip", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<label class=\"relative inline-flex cursor-pointer items-center\"><input type=\"checkbox\" class=\"peer sr-only\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"text-xl\">") if !ContainsString(hiddenCollections, section.ID) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "><div class=\"h-6 w-11 rounded-full bg-surface-hover transition-colors after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-content-muted after:transition-all peer-checked:bg-brand peer-checked:after:translate-x-full peer-checked:after:bg-white peer-focus-visible:ring-2 peer-focus-visible:ring-brand/50\"></div></label></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</div><div class=\"mb-6\"><label class=\"mb-2 block text-sm font-medium text-content-muted\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 106}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span><div class=\"flex items-center gap-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 192, Col: 84} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 209, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" class=\"h-2 w-full cursor-pointer appearance-none rounded-full bg-surface-hover\" 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
}
if section.IsSystem {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<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, 60, "</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, 61, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 42}
}
_, 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, 62, "\" 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, 63, "<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, 64, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "><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, 66, "</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_Var32 string
templ_7745c5c3_Var32, 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: 128}
}
_, 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, 67, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, 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}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+155 -169
View File
@@ -11,21 +11,16 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
<title>Device Management - Bookhoard</title> <title>Device Management - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ user.Theme }" x-data="devices" x-init="setupEventDelegation()"> <body class="theme-{ user.Theme }" x-data="devices" x-init="setupEventDelegation()">
@Header(user, "/devices") @Header(user, "/devices")
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<!-- Header Section -->
<div class="mb-8"> <div class="mb-8">
<div class="flex justify-between items-center gap-4 flex-wrap"> <div class="flex justify-between items-center">
<div> <div>
<div class="flex items-center gap-3 mb-1"> <h1 class="text-2xl font-bold tracking-tight text-content">Device Management</h1>
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);"> <p class="text-content-muted">Manage your reading devices and sync settings</p>
@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> </div>
<button @click="showAddDeviceModal()" class="btn btn-primary"> <button @click="showAddDeviceModal()" class="btn btn-primary">
@Icon("plus", "h-4 w-4") @Icon("plus", "h-4 w-4")
@@ -33,140 +28,159 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</button> </button>
</div> </div>
</div> </div>
<!-- Devices Grid -->
<div id="devices-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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 { if len(devices) == 0 {
<div id="empty-state" class="card text-center py-16 col-span-full"> <div id="empty-state" class="text-center py-16 col-span-full text-content-muted">
<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);"> <div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand">
@Icon("device", "h-7 w-7") @Icon("device", "h-7 w-7")
</span> </div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Devices Yet</h3> <h3 class="text-xl font-semibold mb-2 text-content">No Devices Yet</h3>
<p class="mb-4 text-sm" style="color: var(--text-secondary)">Add your reading devices to enable cross-device sync</p> <p class="mb-4">Add your reading devices to enable cross-device sync</p>
<button @click="showAddDeviceModal()" class="btn btn-primary">Add Your First Device</button> <button @click="showAddDeviceModal()" class="btn btn-primary">
Add Your First Device
</button>
</div> </div>
} }
<!-- Devices Grid -->
if len(devices) > 0 { if len(devices) > 0 {
<div id="devices-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 col-span-full"> <div id="devices-grid" class="grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
for _, device := range devices { for _, device := range devices {
<div class="card p-6"> <div class="card p-6">
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<span class="grid place-items-center h-11 w-11 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);"> <div class="text-4xl">
if device.DeviceType == "koreader" { if device.DeviceType == "koreader" {
@Icon("book-open", "h-5 w-5") 📖
} else if device.DeviceType == "kobo" { } else if device.DeviceType == "kobo" {
@Icon("library", "h-5 w-5") 📚
} else if device.DeviceType == "web" { } else if device.DeviceType == "web" {
@Icon("globe", "h-5 w-5") 🌐
} else { } else {
@Icon("device", "h-5 w-5") 📱
} }
</span> </div>
<div class="flex space-x-1"> <div class="flex space-x-2">
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="icon-btn" title="Shelf mappings" aria-label="Shelf mappings"> <button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="icon-btn" aria-label="Shelf mappings">
@Icon("layers", "h-5 w-5") @Icon("library", "h-4 w-4")
</button> </button>
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="icon-btn" title="Device settings" aria-label="Device settings"> <button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="icon-btn" aria-label="Device settings">
@Icon("gear", "h-5 w-5") @Icon("settings", "h-4 w-4")
</button> </button>
</div> </div>
</div> </div>
<h3 class="text-lg font-semibold mb-1" style="color: var(--text-primary)">{ device.DeviceName }</h3> <h3 class="text-lg font-semibold mb-1 text-content">{ device.DeviceName }</h3>
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ device.DeviceType }</p> <p class="text-sm mb-4 text-content-muted">{ device.DeviceType }</p>
<div class="space-y-2 text-sm mb-4"> <div class="space-y-2 text-sm mb-4">
<div class="flex justify-between"> <div class="flex justify-between">
<span style="color: var(--text-secondary)">Sync Status</span> <span class="text-content-muted">Sync Status</span>
if device.SyncEnabled { if device.SyncEnabled {
<span class="badge status-completed">Enabled</span> <span class="inline-flex items-center gap-1 text-brand">
@Icon("check", "h-3.5 w-3.5")
Enabled
</span>
} else { } else {
<span class="badge" style="background-color: var(--surface-hover); color: var(--text-secondary);">Disabled</span> <span class="inline-flex items-center gap-1 text-content-muted">
@Icon("close", "h-3.5 w-3.5")
Disabled
</span>
} }
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span style="color: var(--text-secondary)">Last Sync</span> <span class="text-content-muted">Last Sync</span>
if device.LastSync != nil { if device.LastSync != nil {
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span> <span class="text-content">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span>
} else { } else {
<span style="color: var(--text-secondary)">Never</span> <span class="text-content">Never</span>
} }
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span style="color: var(--text-secondary)">Last Seen</span> <span class="text-content-muted">Last Seen</span>
if device.LastSeen != nil { if device.LastSeen != nil {
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span> <span class="text-content">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span>
} else { } else {
<span style="color: var(--text-secondary)">Never</span> <span class="text-content">Never</span>
} }
</div> </div>
</div> </div>
<div class="border-t pt-4" style="border-color: var(--border);"> <!-- NEW: Sync URL & Token Management -->
<p class="text-xs font-semibold uppercase tracking-wide mb-3" style="color: var(--text-secondary)">Device Sync Configuration</p> <div class="border-t border-line pt-4">
<p class="text-xs font-semibold mb-2 text-content-muted">DEVICE SYNC CONFIGURATION</p>
if device.DeviceType == "kobo" { if device.DeviceType == "kobo" {
<!-- Kobo: Copy Full Sync URL -->
<div class="mb-3"> <div class="mb-3">
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Kobo Sync URL</label>
<div class="flex space-x-2"> <div class="flex space-x-2">
<input <input
type="text" type="text"
id="sync-url-{ device.ID }" id="sync-url-{ device.ID }"
readonly readonly
value={ baseURL + "/api/sync/kobo/" + device.AuthToken } value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
class="input text-xs font-mono" class="input flex-1 text-xs"
/> />
<button <button
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')" @click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
class="btn btn-primary text-xs px-3" class="btn btn-primary text-xs"
> >
@Icon("copy", "h-4 w-4") @Icon("copy", "h-3.5 w-3.5")
Copy Copy
</button> </button>
</div> </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> <p class="text-xs mt-1 text-content-muted">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded bg-surface">api_endpoint</code> setting</p>
</div> </div>
} }
if device.DeviceType == "koreader" { if device.DeviceType == "koreader" {
<!-- KOReader: Copy Auth Token -->
<div class="mb-3"> <div class="mb-3">
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Auth Token (for plugin)</label>
<div class="flex space-x-2"> <div class="flex space-x-2">
<input <input
type="text" type="text"
id="auth-token-{ device.ID }" id="auth-token-{ device.ID }"
readonly readonly
value={ device.AuthToken } value={ device.AuthToken }
class="input text-xs font-mono" class="input flex-1 text-xs font-mono"
/> />
<button <button
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')" @click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
class="btn btn-primary text-xs px-3" class="btn btn-primary text-xs"
> >
@Icon("copy", "h-4 w-4") @Icon("copy", "h-3.5 w-3.5")
Copy Copy
</button> </button>
</div> </div>
<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p> <p class="text-xs mt-1 text-content-muted">Enter this token in the KOReader plugin settings</p>
</div> </div>
} }
<!-- Regenerate Token Button -->
<button <button
hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" } hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
hx-confirm="Old token will immediately stop working. Regenerate anyway?" hx-confirm="⚠️ Old token will immediately stop working. Regenerate anyway?"
class="btn btn-secondary w-full text-xs" class="btn btn-secondary w-full text-xs"
> >
@Icon("refresh", "h-4 w-4") @Icon("refresh", "h-3.5 w-3.5")
Regenerate Token Regenerate Token
</button> </button>
<p class="text-xs mt-1" style="color: var(--status-warning);">Old token will immediately stop working</p> <p class="text-xs mt-1 inline-flex items-center gap-1 text-content-muted">
@Icon("alert", "h-3 w-3")
Old token will immediately stop working
</p>
</div> </div>
</div> </div>
} }
</div> </div>
} }
</div> </div>
<!-- Pending Registrations -->
if len(pendingRegistrations) > 0 { if len(pendingRegistrations) > 0 {
<div id="pending-section" class="mt-12"> <div id="pending-section" class="mt-12">
<h2 class="text-lg font-bold tracking-tight mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2> <h2 class="text-2xl font-bold tracking-tight mb-4 text-content">Pending Device Registrations</h2>
<div class="space-y-4"> <div class="space-y-4">
for _, reg := range pendingRegistrations { for _, reg := range pendingRegistrations {
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap"> <div class="card p-4 flex items-center justify-between">
<div> <div>
<h3 class="font-semibold" style="color: var(--text-primary)">{ reg.DeviceName }</h3> <h3 class="font-semibold text-content">{ reg.DeviceName }</h3>
<p class="text-sm" style="color: var(--text-secondary)"> <p class="text-sm text-content-muted">
{ reg.DeviceType } - Expires in { reg.ExpiresAt } { reg.DeviceType } - Expires in { reg.ExpiresAt }
</p> </p>
</div> </div>
@@ -183,99 +197,53 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</div> </div>
</div> </div>
} }
<!-- Sync Queue Section -->
<div id="sync-queue-section" class="mt-12 hidden"> <div id="sync-queue-section" class="mt-12 hidden">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Sync Queue</h2> <h2 class="text-2xl font-bold tracking-tight text-content">Sync Queue</h2>
<button @click="clearSyncQueue()" class="btn btn-secondary">Clear Queue</button> <button @click="clearSyncQueue()" class="btn btn-secondary">
Clear Queue
</button>
</div> </div>
<div id="sync-queue" class="space-y-3"></div> <div id="sync-queue" class="space-y-3"></div>
</div> </div>
</div> </div>
<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);"> <!-- Add Device Modal -->
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);"> <div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" 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"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2> <h2 class="text-xl font-bold text-content">Add New Device</h2>
<button @click="hideAddDeviceModal()" class="icon-btn" aria-label="Close"> <button @click="hideAddDeviceModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<form id="add-device-form" @submit="handleAddDevice($event)">
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Type</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Name</label>
<input
type="text"
id="device-name"
required
class="input"
placeholder="My Kindle Paperwhite"
/>
</div>
<div class="mb-4">
<label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Type</label>
<select <select
id="device-type" id="device-type"
x-model="selectedType"
required required
class="input" class="input"
> >
<option value="">Select device type...</option> <option value="">Select device type...</option>
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option> <option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
<option value="kobo">Kobo E-Reader</option> <option value="kobo">Kobo E-Reader</option>
<!-- <option value="web">Web Browser</option> --> <option value="web">Web Browser</option>
<!-- <option value="mobile">Mobile App</option> --> <option value="mobile">Mobile App</option>
</select> </select>
</div> </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-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
<input
type="text"
id="device-name"
required
class="input"
placeholder="My Kobo"
/>
</div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Identifier</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Identifier</label>
<input <input
type="text" type="text"
id="device-identifier" id="device-identifier"
@@ -283,20 +251,24 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
class="input" class="input"
placeholder="Hardware ID or Serial Number" placeholder="Hardware ID or Serial Number"
/> />
<p class="text-xs mt-1" style="color: var(--text-secondary)">Enter your device's unique identifier</p> <p class="text-xs mt-1 text-content-muted">Enter your device's unique identifier</p>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" @click="hideAddDeviceModal()" class="btn btn-secondary">Cancel</button> <button type="button" @click="hideAddDeviceModal()" class="btn btn-secondary">
<button type="submit" class="btn btn-primary">Register Device</button> Cancel
</button>
<button type="submit" class="btn btn-primary">
Register Device
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> <!-- Device Settings Modal -->
<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 id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);"> <div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Device Settings</h2> <h2 class="text-xl font-bold text-content">Device Settings</h2>
<button @click="hideDeviceSettingsModal()" class="icon-btn" aria-label="Close"> <button @click="hideDeviceSettingsModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
@@ -305,7 +277,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
<input type="hidden" id="settings-device-id"/> <input type="hidden" id="settings-device-id"/>
<input type="hidden" id="settings-device-type"/> <input type="hidden" id="settings-device-type"/>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Name</label>
<input <input
type="text" type="text"
id="settings-device-name" id="settings-device-name"
@@ -321,9 +293,9 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
checked checked
class="w-5 h-5 rounded" class="w-5 h-5 rounded"
/> />
<span style="color: var(--text-primary)">Enable Sync</span> <span class="text-content">Enable Sync</span>
</label> </label>
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Allow this device to sync reading progress</p> <p class="text-xs mt-1 ml-8 text-content-muted">Allow this device to sync reading progress</p>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="flex items-center space-x-3 cursor-pointer"> <label class="flex items-center space-x-3 cursor-pointer">
@@ -333,12 +305,12 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
checked checked
class="w-5 h-5 rounded" class="w-5 h-5 rounded"
/> />
<span style="color: var(--text-primary)">Auto Sync</span> <span class="text-content">Auto Sync</span>
</label> </label>
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Automatically sync changes</p> <p class="text-xs mt-1 ml-8 text-content-muted">Automatically sync changes</p>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Frequency</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Sync Frequency</label>
<select <select
id="settings-sync-frequency" id="settings-sync-frequency"
class="input" class="input"
@@ -350,11 +322,11 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
<option value="60">Every hour</option> <option value="60">Every hour</option>
</select> </select>
</div> </div>
<div class="border-t pt-6 mb-6" style="border-color: var(--border);"> <div class="border-t border-line pt-6 mb-6">
<h3 class="text-base font-semibold mb-1" style="color: var(--text-primary)">Collection View Settings</h3> <h3 class="text-lg font-semibold mb-4 text-content">Collection View Settings</h3>
<p class="text-sm mb-4" style="color: var(--text-secondary)">Configure how collections are displayed on this device</p> <p class="text-sm mb-4 text-content-muted">Configure how collections are displayed on this device</p>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default View Mode</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Default View Mode</label>
<select <select
id="settings-view-mode" id="settings-view-mode"
class="input" class="input"
@@ -365,7 +337,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sort Collections By</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Sort Collections By</label>
<select <select
id="settings-sort-order" id="settings-sort-order"
class="input" class="input"
@@ -377,7 +349,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Items Per Page</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Items Per Page</label>
<select <select
id="settings-items-per-page" id="settings-items-per-page"
class="input" class="input"
@@ -396,9 +368,9 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
checked checked
class="w-5 h-5 rounded" class="w-5 h-5 rounded"
/> />
<span style="color: var(--text-primary)">Show Cover Images</span> <span class="text-content">Show Cover Images</span>
</label> </label>
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Display book covers in collection views</p> <p class="text-xs mt-1 ml-8 text-content-muted">Display book covers in collection views</p>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="flex items-center space-x-3 cursor-pointer"> <label class="flex items-center space-x-3 cursor-pointer">
@@ -407,33 +379,42 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
id="settings-show-progress" id="settings-show-progress"
class="w-5 h-5 rounded" class="w-5 h-5 rounded"
/> />
<span style="color: var(--text-primary)">Show Reading Progress</span> <span class="text-content">Show Reading Progress</span>
</label> </label>
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Display progress indicators for books</p> <p class="text-xs mt-1 ml-8 text-content-muted">Display progress indicators for books</p>
</div> </div>
</div> </div>
<div class="flex justify-end space-x-3 flex-wrap"> <div class="flex justify-end space-x-3">
<button type="button" @click="hideDeviceSettingsModal()" class="btn btn-secondary">Cancel</button> <button type="button" @click="hideDeviceSettingsModal()" class="btn btn-secondary">
<button type="button" @click="handleRevokeDevice()" class="btn btn-danger">Revoke Device</button> Cancel
<button type="submit" class="btn btn-primary">Save Settings</button> </button>
<button type="button" @click="handleRevokeDevice()" class="btn btn-danger">
Revoke Device
</button>
<button type="submit" class="btn btn-primary">
Save Settings
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<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);"> <!-- Device Shelf Mappings Modal -->
<div class="card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto" style="box-shadow: var(--shadow-pop);"> <div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-6"> <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> <h2 class="text-xl font-bold text-content">Collection to Shelf Mappings</h2>
<button @click="hideShelfMappingsModal()" class="icon-btn" aria-label="Close"> <button @click="hideShelfMappingsModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<input type="hidden" id="mappings-device-id"/> <input type="hidden" id="mappings-device-id"/>
<div id="shelf-mappings-container" class="space-y-4 mb-6"> <div id="shelf-mappings-container" class="space-y-4 mb-6">
<p style="color: var(--text-secondary)">Loading mappings...</p> <p class="text-content-muted">Loading mappings...</p>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" @click="hideShelfMappingsModal()" class="btn btn-secondary">Close</button> <button type="button" @click="hideShelfMappingsModal()" class="btn btn-secondary">
Close
</button>
<button type="button" @click="showAddMappingModal()" class="btn btn-primary"> <button type="button" @click="showAddMappingModal()" class="btn btn-primary">
@Icon("plus", "h-4 w-4") @Icon("plus", "h-4 w-4")
Add Mapping Add Mapping
@@ -441,10 +422,11 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</div> </div>
</div> </div>
</div> </div>
<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);"> <!-- Add/Edit Shelf Mapping Modal -->
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);"> <div id="add-mapping-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" 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"> <div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Collection Mapping</h2> <h2 class="text-xl font-bold text-content">Add Collection Mapping</h2>
<button @click="hideAddMappingModal()" class="icon-btn" aria-label="Close"> <button @click="hideAddMappingModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
@@ -453,7 +435,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
<input type="hidden" id="mapping-device-id"/> <input type="hidden" id="mapping-device-id"/>
<input type="hidden" id="mapping-id"/> <input type="hidden" id="mapping-id"/>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Collection</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Collection</label>
<select <select
id="mapping-collection" id="mapping-collection"
required required
@@ -463,7 +445,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Shelf Name</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Device Shelf Name</label>
<input <input
type="text" type="text"
id="mapping-shelf-name" id="mapping-shelf-name"
@@ -471,10 +453,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
class="input" class="input"
placeholder="e.g., Sci-Fi" placeholder="e.g., Sci-Fi"
/> />
<p class="text-xs mt-1" style="color: var(--text-secondary)">Name of the shelf on this device</p> <p class="text-xs mt-1 text-content-muted">Name of the shelf on this device</p>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Direction</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Sync Direction</label>
<select <select
id="mapping-sync-direction" id="mapping-sync-direction"
required required
@@ -487,8 +469,12 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</select> </select>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" @click="hideAddMappingModal()" class="btn btn-secondary">Cancel</button> <button type="button" @click="hideAddMappingModal()" class="btn btn-secondary">
<button type="submit" class="btn btn-primary">Save Mapping</button> Cancel
</button>
<button type="submit" class="btn btn-primary">
Save Mapping
</button>
</div> </div>
</form> </form>
</div> </div>
+101 -131
View File
@@ -31,7 +31,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Device Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\" x-data=\"devices\" x-init=\"setupEventDelegation()\">") 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>Device Management - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\" x-data=\"devices\" x-init=\"setupEventDelegation()\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -39,15 +39,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><!-- Header Section --><div class=\"mb-8\"><div class=\"flex justify-between items-center\"><div><h1 class=\"text-2xl font-bold tracking-tight text-content\">Device Management</h1><p class=\"text-content-muted\">Manage your reading devices and sync settings</p></div><button @click=\"showAddDeviceModal()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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, 3, "</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 btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -55,12 +47,12 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Add New Device</button></div></div><div id=\"devices-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "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 templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(devices) == 0 { if len(devices) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div id=\"empty-state\" class=\"text-center py-16 col-span-full text-content-muted\"><div class=\"mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-brand/15 text-brand\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -68,43 +60,47 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Devices Yet</h3><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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><h3 class=\"text-xl font-semibold mb-2 text-content\">No Devices Yet</h3><p class=\"mb-4\">Add your reading devices to enable cross-device sync</p><button @click=\"showAddDeviceModal()\" class=\"btn btn-primary\">Add Your First Device</button></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Devices Grid -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(devices) > 0 { if len(devices) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div id=\"devices-grid\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 col-span-full\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div id=\"devices-grid\" class=\"grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, device := range devices { for _, device := range devices {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"card p-6\"><div class=\"flex items-start justify-between mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"card p-6\"><div class=\"flex items-start justify-between mb-4\"><div class=\"text-4xl\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if device.DeviceType == "koreader" { if device.DeviceType == "koreader" {
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "📖")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else if device.DeviceType == "kobo" { } else if device.DeviceType == "kobo" {
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "📚")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else if device.DeviceType == "web" { } else if device.DeviceType == "web" {
templ_7745c5c3_Err = Icon("globe", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "🌐")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = Icon("device", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "📱")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span><div class=\"flex space-x-1\"><button @click=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div class=\"flex space-x-2\"><button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -117,307 +113,331 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"icon-btn\" title=\"Shelf mappings\" aria-label=\"Shelf mappings\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"icon-btn\" aria-label=\"Shelf mappings\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("layers", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("library", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button> <button @click=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button> <button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')") templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 78}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"icon-btn\" title=\"Device settings\" aria-label=\"Device settings\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"icon-btn\" aria-label=\"Device settings\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("gear", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("settings", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</button></div></div><h3 class=\"text-lg font-semibold mb-1\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div></div><h3 class=\"text-lg font-semibold mb-1 text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 80}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h3><p class=\"text-sm mb-4 text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Sync Status</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex justify-between\"><span class=\"text-content-muted\">Sync Status</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if device.SyncEnabled { if device.SyncEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"badge status-completed\">Enabled</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"inline-flex items-center gap-1 text-brand\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check", "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, 21, "Enabled</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge\" style=\"background-color: var(--surface-hover); color: var(--text-secondary);\">Disabled</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"inline-flex items-center gap-1 text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "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, 23, "Disabled</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Last Sync</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><div class=\"flex justify-between\"><span class=\"text-content-muted\">Last Sync</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if device.LastSync != nil { if device.LastSync != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span class=\"text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 104} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 92, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span style=\"color: var(--text-secondary)\">Never</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"text-content\">Never</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div><div class=\"flex justify-between\"><span style=\"color: var(--text-secondary)\">Last Seen</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"flex justify-between\"><span class=\"text-content-muted\">Last Seen</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if device.LastSeen != nil { if device.LastSeen != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone)) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 104} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 100, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span style=\"color: var(--text-secondary)\">Never</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"text-content\">Never</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div><div class=\"border-t pt-4\" style=\"border-color: var(--border);\"><p class=\"text-xs font-semibold uppercase tracking-wide mb-3\" style=\"color: var(--text-secondary)\">Device Sync Configuration</p>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></div><!-- NEW: Sync URL & Token Management --><div class=\"border-t border-line pt-4\"><p class=\"text-xs font-semibold mb-2 text-content-muted\">DEVICE SYNC CONFIGURATION</p>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if device.DeviceType == "kobo" { if device.DeviceType == "kobo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<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\"><input type=\"text\" id=\"sync-url-{ device.ID }\" readonly value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<!-- Kobo: Copy Full Sync URL --> <div class=\"mb-3\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Kobo Sync URL</label><div class=\"flex space-x-2\"><input type=\"text\" id=\"sync-url-{ device.ID }\" readonly value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 110, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 118, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"input text-xs font-mono\"> <button @click=\"copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')\" class=\"btn btn-primary text-xs px-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"input flex-1 text-xs\"> <button @click=\"copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')\" class=\"btn btn-primary text-xs\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("copy", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "Copy</button></div><p class=\"text-xs mt-1 text-content-muted\">Paste this URL into Kobo's <code class=\"px-1 py-0.5 rounded bg-surface\">api_endpoint</code> setting</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if device.DeviceType == "koreader" { if device.DeviceType == "koreader" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<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\"><input type=\"text\" id=\"auth-token-{ device.ID }\" readonly value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<!-- KOReader: Copy Auth Token --> <div class=\"mb-3\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Auth Token (for plugin)</label><div class=\"flex space-x-2\"><input type=\"text\" id=\"auth-token-{ device.ID }\" readonly value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 132, Col: 38} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 141, Col: 38}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"input text-xs font-mono\"> <button @click=\"copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')\" class=\"btn btn-primary text-xs px-3\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"input flex-1 text-xs font-mono\"> <button @click=\"copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')\" class=\"btn btn-primary text-xs\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("copy", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Copy</button></div><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary);\">Enter this token in the KOReader plugin settings</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Copy</button></div><p class=\"text-xs mt-1 text-content-muted\">Enter this token in the KOReader plugin settings</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button hx-put=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<!-- Regenerate Token Button --><button hx-put=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token") templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 147, Col: 78} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 157, Col: 78}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-confirm=\"Old token will immediately stop working. Regenerate anyway?\" class=\"btn btn-secondary w-full text-xs\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-confirm=\"⚠️ Old token will immediately stop working. Regenerate anyway?\" class=\"btn btn-secondary w-full text-xs\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("refresh", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "Regenerate Token</button><p class=\"text-xs mt-1\" style=\"color: var(--status-warning);\">Old token will immediately stop working</p></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Regenerate Token</button><p class=\"text-xs mt-1 inline-flex items-center gap-1 text-content-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("alert", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Old token will immediately stop working</p></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div><!-- Pending Registrations -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(pendingRegistrations) > 0 { if len(pendingRegistrations) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div id=\"pending-section\" class=\"mt-12\"><h2 class=\"text-lg font-bold tracking-tight mb-4\" style=\"color: var(--text-primary)\">Pending Device Registrations</h2><div class=\"space-y-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div id=\"pending-section\" class=\"mt-12\"><h2 class=\"text-2xl font-bold tracking-tight mb-4 text-content\">Pending Device Registrations</h2><div class=\"space-y-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, reg := range pendingRegistrations { for _, reg := range pendingRegistrations {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"card p-4 flex items-center justify-between gap-4 flex-wrap\"><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"card p-4 flex items-center justify-between\"><div><h3 class=\"font-semibold text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 168, Col: 87} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 182, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</h3><p class=\"text-sm text-content-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 184, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " - Expires in ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " - Expires in ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 184, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p></div><div class=\"flex space-x-2\"><button @click=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</p></div><div class=\"flex space-x-2\"><button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')") templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 174, Col: 72} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 188, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"btn btn-primary text-sm\">Approve</button> <button @click=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" class=\"btn btn-primary text-sm\">Approve</button> <button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')") templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 177, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 191, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" class=\"btn btn-danger text-sm\">Reject</button></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" class=\"btn btn-danger text-sm\">Reject</button></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div id=\"sync-queue-section\" class=\"mt-12 hidden\"><div class=\"flex justify-between items-center mb-4\"><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><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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<!-- 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 tracking-tight text-content\">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: 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 text-content\">Add New Device</h2><button @click=\"hideAddDeviceModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -425,57 +445,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</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)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><form id=\"add-device-form\" @submit=\"handleAddDevice($event)\"><div class=\"mb-4\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Device Name</label> <input type=\"text\" id=\"device-name\" required class=\"input\" placeholder=\"My Kindle Paperwhite\"></div><div class=\"mb-4\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Device Type</label> <select id=\"device-type\" 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 class=\"mb-6\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Device Identifier</label> <input type=\"text\" id=\"device-identifier\" required class=\"input\" placeholder=\"Hardware ID or Serial Number\"><p class=\"text-xs mt-1 text-content-muted\">Enter your device's unique identifier</p></div><div class=\"flex justify-end space-x-3\"><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: 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 text-content\">Device Settings</h2><button @click=\"hideDeviceSettingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("book-open", "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, 48, "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);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 242, Col: 148}
}
_, 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, 49, "</code> <button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 244, Col: 68}
}
_, 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, 50, "\" class=\"btn btn-secondary text-xs px-3\">")
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, 51, "</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);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "h-4 w-4 flex-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, 52, "<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-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Device Name</label> <input type=\"text\" id=\"device-name\" required class=\"input\" placeholder=\"My Kobo\"></div><div class=\"mb-6\"><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=\"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 btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Register Device</button></div></form></div></div></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=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -483,7 +453,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</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-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=\"input\"></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-sync-enabled\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Enable Sync</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Allow this device to sync reading progress</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-auto-sync\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Auto Sync</span></label><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-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Frequency</label> <select id=\"settings-sync-frequency\" class=\"input\"><option value=\"1\">Every 1 minute</option> <option value=\"5\" selected>Every 5 minutes</option> <option value=\"15\">Every 15 minutes</option> <option value=\"30\">Every 30 minutes</option> <option value=\"60\">Every hour</option></select></div><div class=\"border-t pt-6 mb-6\" style=\"border-color: var(--border);\"><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-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Default View Mode</label> <select id=\"settings-view-mode\" class=\"input\"><option value=\"grid\">Grid View</option> <option value=\"list\">List View</option> <option value=\"compact\">Compact View</option></select></div><div class=\"mb-4\"><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=\"input\"><option value=\"name\">Collection Name</option> <option value=\"created\">Date Created</option> <option value=\"book_count\">Book Count</option> <option value=\"recent\">Recently Added</option></select></div><div class=\"mb-4\"><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=\"input\"><option value=\"12\">12 items</option> <option value=\"24\" selected>24 items</option> <option value=\"48\">48 items</option> <option value=\"96\">96 items</option></select></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-covers\" checked class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Cover Images</span></label><p class=\"text-xs mt-1 ml-8\" style=\"color: var(--text-secondary)\">Display book covers in collection views</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-progress\" class=\"w-5 h-5 rounded\"> <span style=\"color: var(--text-primary)\">Show Reading Progress</span></label><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 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><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=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</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=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Device Name</label> <input type=\"text\" id=\"settings-device-name\" required class=\"input\"></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-sync-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-content\">Enable Sync</span></label><p class=\"text-xs mt-1 ml-8 text-content-muted\">Allow this device to sync reading progress</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-auto-sync\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-content\">Auto Sync</span></label><p class=\"text-xs mt-1 ml-8 text-content-muted\">Automatically sync changes</p></div><div class=\"mb-6\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Sync Frequency</label> <select id=\"settings-sync-frequency\" class=\"input\"><option value=\"1\">Every 1 minute</option> <option value=\"5\" selected>Every 5 minutes</option> <option value=\"15\">Every 15 minutes</option> <option value=\"30\">Every 30 minutes</option> <option value=\"60\">Every hour</option></select></div><div class=\"border-t border-line pt-6 mb-6\"><h3 class=\"text-lg font-semibold mb-4 text-content\">Collection View Settings</h3><p class=\"text-sm mb-4 text-content-muted\">Configure how collections are displayed on this device</p><div class=\"mb-4\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Default View Mode</label> <select id=\"settings-view-mode\" class=\"input\"><option value=\"grid\">Grid View</option> <option value=\"list\">List View</option> <option value=\"compact\">Compact View</option></select></div><div class=\"mb-4\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Sort Collections By</label> <select id=\"settings-sort-order\" class=\"input\"><option value=\"name\">Collection Name</option> <option value=\"created\">Date Created</option> <option value=\"book_count\">Book Count</option> <option value=\"recent\">Recently Added</option></select></div><div class=\"mb-4\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Items Per Page</label> <select id=\"settings-items-per-page\" class=\"input\"><option value=\"12\">12 items</option> <option value=\"24\" selected>24 items</option> <option value=\"48\">48 items</option> <option value=\"96\">96 items</option></select></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-covers\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-content\">Show Cover Images</span></label><p class=\"text-xs mt-1 ml-8 text-content-muted\">Display book covers in collection views</p></div><div class=\"mb-4\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"settings-show-progress\" class=\"w-5 h-5 rounded\"> <span class=\"text-content\">Show Reading Progress</span></label><p class=\"text-xs mt-1 ml-8 text-content-muted\">Display progress indicators for books</p></div></div><div class=\"flex justify-end space-x-3\"><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: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold text-content\">Collection to Shelf Mappings</h2><button @click=\"hideShelfMappingsModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -491,7 +461,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</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 btn-secondary\">Close</button> <button type=\"button\" @click=\"showAddMappingModal()\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</button></div><input type=\"hidden\" id=\"mappings-device-id\"><div id=\"shelf-mappings-container\" class=\"space-y-4 mb-6\"><p class=\"text-content-muted\">Loading mappings...</p></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideShelfMappingsModal()\" class=\"btn btn-secondary\">Close</button> <button type=\"button\" @click=\"showAddMappingModal()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -499,7 +469,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Add Mapping</button></div></div></div><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=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "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: 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 text-content\">Add Collection Mapping</h2><button @click=\"hideAddMappingModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -507,7 +477,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</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-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Collection</label> <select id=\"mapping-collection\" required class=\"input\"><option value=\"\">Select collection...</option></select></div><div class=\"mb-4\"><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=\"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-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Sync Direction</label> <select id=\"mapping-sync-direction\" required class=\"input\"><option value=\"bidirectional\">Bidirectional (sync both ways)</option> <option value=\"book_to_device\">Bookhoard → Device</option> <option value=\"device_to_book\">Device → Bookhoard</option> <option value=\"none\">No sync</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddMappingModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save Mapping</button></div></form></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</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=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Collection</label> <select id=\"mapping-collection\" required class=\"input\"><option value=\"\">Select collection...</option></select></div><div class=\"mb-4\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Device Shelf Name</label> <input type=\"text\" id=\"mapping-shelf-name\" required class=\"input\" placeholder=\"e.g., Sci-Fi\"><p class=\"text-xs mt-1 text-content-muted\">Name of the shelf on this device</p></div><div class=\"mb-6\"><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Sync Direction</label> <select id=\"mapping-sync-direction\" required class=\"input\"><option value=\"bidirectional\">Bidirectional (sync both ways)</option> <option value=\"book_to_device\">Bookhoard → Device</option> <option value=\"device_to_book\">Device → Bookhoard</option> <option value=\"none\">No sync</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"hideAddMappingModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save Mapping</button></div></form></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -515,7 +485,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+39 -43
View File
@@ -10,105 +10,101 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ doc.Title } - Bookhoard Documentation</title> <title>{ doc.Title } - Bookhoard Documentation</title>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<link rel="stylesheet" href="/static/highlight-dark.min.css"/> <link rel="stylesheet" href="/static/highlight-dark.min.css"/>
</head> </head>
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }> <body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs bg-surface text-content font-sans antialiased" }>
@Header(user, currentPath) @Header(user, currentPath)
<!-- Mobile Menu Button -->
<button <button
class="lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised" class="icon-btn lg:hidden fixed top-4 left-4 z-50"
onclick="toggleSidebar()" onclick="toggleSidebar()"
aria-label="Toggle documentation menu" aria-label="Toggle menu"
> >
@Icon("menu", "h-5 w-5") @Icon("menu", "h-6 w-6")
</button> </button>
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="toggleSidebar()"></div> <!-- Search Results Overlay -->
<div id="search-results" class="hidden fixed inset-0 z-50 overflow-y-auto p-8" style="background: var(--surface-overlay);"></div> <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>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <!-- Sidebar -->
<div class="flex gap-8 py-8"> <div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-surface-raised border-r border-line overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out">
<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"> <!-- Search -->
<div class="p-4 border-b border-line"> <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 <input
type="text" type="text"
class="search-input input pl-9" class="search-input input text-sm"
placeholder="Search documentation" placeholder="Search documentation..."
id="docs-search" id="docs-search"
/> />
</div> </div>
</div> <!-- Navigation Sections -->
for _, section := range nav.Sections { for _, section := range nav.Sections {
<div class="nav-section border-b border-line"> <div class="nav-section mb-6">
<div <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" class="nav-section-title font-semibold text-xs uppercase tracking-wide text-content-muted px-4 py-3 cursor-pointer select-none flex items-center justify-between"
style="color: var(--text-secondary);"
@click="toggleSection($el)" @click="toggleSection($el)"
> >
<span>{ section.Title }</span> <span>{ section.Title }</span>
@Icon("chevron-down", "h-4 w-4 section-arrow transition-transform") <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> </div>
if section.Collapsed { if section.Collapsed {
<div class="nav-items hidden" data-collapsed="true"> <div class="nav-items hidden" data-collapsed="true">
for _, item := range section.Items { 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);"> <a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-content-muted hover:text-brand text-sm transition-colors no-underline">
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span> <span class="mr-2">{ item.Icon }</span>{ item.Title }
<span>{ item.Title }</span>
</a> </a>
} }
</div> </div>
} else { } else {
<div class="nav-items" data-collapsed="false"> <div class="nav-items" data-collapsed="false">
for _, item := range section.Items { 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);"> <a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-content-muted hover:text-brand text-sm transition-colors no-underline">
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span> <span class="mr-2">{ item.Icon }</span>{ item.Title }
<span>{ item.Title }</span>
</a> </a>
} }
</div> </div>
} }
</div> </div>
} }
</aside> </div>
<main class="main-content flex-1 min-w-0 max-w-4xl"> <!-- Main Content -->
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
<!-- Breadcrumb -->
if len(doc.Breadcrumb) > 0 { 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);"> <nav class="breadcrumb flex gap-2 text-sm text-content-muted mb-8" aria-label="Breadcrumb">
for i, crumb := range doc.Breadcrumb { for i, crumb := range doc.Breadcrumb {
if i > 0 { if i > 0 {
@Icon("chevron-right", "h-4 w-4") <span class="text-content-muted/50"></span>
} }
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a> <a href={ crumb.URL } class="text-brand hover:underline no-underline">{ crumb.Title }</a>
} }
</nav> </nav>
} }
<h1 class="text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1> <!-- Title -->
<h1 class="text-3xl font-bold tracking-tight mb-8 text-content">{ doc.Title }</h1>
<!-- Table of Contents -->
if len(doc.TOC) > 0 { if len(doc.TOC) > 0 {
<details class="toc card p-4 mb-8"> <details class="toc bg-surface-raised p-4 rounded-lg mb-8 border border-line">
<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);"> <summary class="cursor-pointer hover:opacity-80 transition-opacity">
<span>On this page</span> <strong class="text-content font-semibold">On this page</strong>
@Icon("chevron-down", "h-4 w-4 ml-auto") <span class="ml-2 text-content-muted text-xs"></span>
</summary> </summary>
<div class="mt-3">
for _, item := range doc.TOC { for _, item := range doc.TOC {
<a <a
href={ "#" + item.Anchor } href={ "#" + item.Anchor }
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand" class="toc-item block py-1 text-content-muted hover:text-brand text-sm no-underline"
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);" } style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
> >
{ item.Title } { item.Title }
</a> </a>
} }
</div>
</details> </details>
} }
<!-- Content -->
<div class="prose prose-invert max-w-none"> <div class="prose prose-invert max-w-none">
@UnsafeHTML(doc.Content).ToComponent() @UnsafeHTML(doc.Content).ToComponent()
</div> </div>
</main> </main>
</div>
</div>
</body> </body>
</html> </html>
} }
+51 -75
View File
@@ -44,11 +44,11 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><link rel=\"stylesheet\" href=\"/static/highlight-dark.min.css\"></head>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"stylesheet\" href=\"/static/highlight-dark.min.css\"></head>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs font-sans antialiased"} var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs bg-surface text-content font-sans antialiased"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
@@ -74,299 +74,275 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<button class=\"lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised\" onclick=\"toggleSidebar()\" aria-label=\"Toggle documentation menu\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<!-- Mobile Menu Button --><button class=\"icon-btn lg:hidden fixed top-4 left-4 z-50\" onclick=\"toggleSidebar()\" aria-label=\"Toggle menu\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("menu", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("menu", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</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-surface-raised border-r border-line overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out\"><!-- Search --><div class=\"p-4 border-b border-line\"><input type=\"text\" class=\"search-input input text-sm\" placeholder=\"Search documentation...\" id=\"docs-search\"></div><!-- Navigation Sections -->")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, section := range nav.Sections { for _, section := range nav.Sections {
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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"nav-section mb-6\"><div class=\"nav-section-title font-semibold text-xs uppercase tracking-wide text-content-muted px-4 py-3 cursor-pointer select-none flex items-center justify-between\" @click=\"toggleSection($el)\"><span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 45, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</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>")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if section.Collapsed { if section.Collapsed {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"nav-items hidden\" data-collapsed=\"true\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, item := range section.Items { for _, item := range section.Items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 templ.SafeURL var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 53, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"nav-item block py-2 px-4 pl-8 text-content-muted hover:text-brand text-sm transition-colors no-underline\"><span class=\"mr-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 54, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 54, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"nav-items\" data-collapsed=\"false\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"nav-items\" data-collapsed=\"false\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, item := range section.Items { for _, item := range section.Items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 templ.SafeURL var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 61, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"nav-item block py-2 px-4 pl-8 text-content-muted hover:text-brand text-sm transition-colors no-underline\"><span class=\"mr-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 62, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> <span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 62, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside><main class=\"main-content flex-1 min-w-0 max-w-4xl\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div><!-- Main Content --><main class=\"main-content lg:ml-72 p-8 max-w-4xl mx-auto\"><!-- Breadcrumb -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(doc.Breadcrumb) > 0 { if len(doc.Breadcrumb) > 0 {
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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<nav class=\"breadcrumb flex gap-2 text-sm text-content-muted mb-8\" aria-label=\"Breadcrumb\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for i, crumb := range doc.Breadcrumb { for i, crumb := range doc.Breadcrumb {
if i > 0 { if i > 0 {
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span class=\"text-content-muted/50\"></span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " <a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " <a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 templ.SafeURL var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 79, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"no-underline transition-colors hover:underline\" style=\"color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"text-brand hover:underline no-underline\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 79, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</nav>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</nav>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<h1 class=\"text-4xl font-bold mb-8 tracking-tight\" style=\"color: var(--text-primary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<!-- Title --><h1 class=\"text-3xl font-bold tracking-tight mb-8 text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 84, Col: 79}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</h1>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</h1><!-- Table of Contents -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(doc.TOC) > 0 { if len(doc.TOC) > 0 {
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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<details class=\"toc bg-surface-raised p-4 rounded-lg mb-8 border border-line\"><summary class=\"cursor-pointer hover:opacity-80 transition-opacity\"><strong class=\"text-content font-semibold\">On this page</strong> <span class=\"ml-2 text-content-muted text-xs\">▼</span></summary> ")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, item := range doc.TOC { for _, item := range doc.TOC {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 templ.SafeURL var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 94, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"toc-item block py-1 text-sm no-underline transition-colors hover:text-brand\" style=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"toc-item block py-1 text-content-muted hover:text-brand text-sm no-underline\" style=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);") templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 66}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div></details>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</details>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"prose prose-invert max-w-none\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<!-- Content --><div class=\"prose prose-invert max-w-none\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -374,7 +350,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div></main></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div></main></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
-1
View File
@@ -7,7 +7,6 @@ templ ErrorPage(message string, errorType string) {
<meta charset="UTF-8"> <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">
<title>Error - Bookhoard</title> <title>Error - Bookhoard</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<style> <style>
* { * {
margin: 0; margin: 0;
+3 -3
View File
@@ -29,14 +29,14 @@ func ErrorPage(message string, errorType string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Error - Bookhoard</title><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><style>\n\t\t\t\t* {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tbox-sizing: border-box;\n\t\t\t\t}\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\t\t\t\t\tbackground: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);\n\t\t\t\t\tmin-height: 100vh;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tcolor: #eee;\n\t\t\t\t}\n\t\t\t\t.error-container {\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tpadding: 2rem;\n\t\t\t\t\tmax-width: 500px;\n\t\t\t\t}\n\t\t\t\t.error-icon {\n\t\t\t\t\tfont-size: 4rem;\n\t\t\t\t\tmargin-bottom: 1rem;\n\t\t\t\t}\n\t\t\t\t.error-title {\n\t\t\t\t\tfont-size: 1.5rem;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tmargin-bottom: 1rem;\n\t\t\t\t\tcolor: #fff;\n\t\t\t\t}\n\t\t\t\t.error-message {\n\t\t\t\t\tfont-size: 1rem;\n\t\t\t\t\tcolor: #ccc;\n\t\t\t\t\tmargin-bottom: 2rem;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t}\n\t\t\t\t.error-actions {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tgap: 1rem;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tflex-wrap: wrap;\n\t\t\t\t}\n\t\t\t\t.btn {\n\t\t\t\t\tpadding: 0.75rem 1.5rem;\n\t\t\t\t\tborder-radius: 0.5rem;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\ttransition: all 0.2s;\n\t\t\t\t\tborder: none;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\tfont-size: 0.95rem;\n\t\t\t\t}\n\t\t\t\t.btn-primary {\n\t\t\t\t\tbackground: #7aa2f7;\n\t\t\t\t\tcolor: #1a1b26;\n\t\t\t\t}\n\t\t\t\t.btn-primary:hover {\n\t\t\t\t\tbackground: #89b4fa;\n\t\t\t\t}\n\t\t\t\t.btn-secondary {\n\t\t\t\t\tbackground: transparent;\n\t\t\t\t\tcolor: #7aa2f7;\n\t\t\t\t\tborder: 1px solid #7aa2f7;\n\t\t\t\t}\n\t\t\t\t.btn-secondary:hover {\n\t\t\t\t\tbackground: rgba(122, 162, 247, 0.1);\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"error-container\"><div class=\"error-icon\">⚠️</div><h1 class=\"error-title\">Something went wrong</h1><p class=\"error-message\">") 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>Error - Bookhoard</title><style>\n\t\t\t\t* {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tbox-sizing: border-box;\n\t\t\t\t}\n\t\t\t\tbody {\n\t\t\t\t\tfont-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\t\t\t\t\tbackground: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);\n\t\t\t\t\tmin-height: 100vh;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tcolor: #eee;\n\t\t\t\t}\n\t\t\t\t.error-container {\n\t\t\t\t\ttext-align: center;\n\t\t\t\t\tpadding: 2rem;\n\t\t\t\t\tmax-width: 500px;\n\t\t\t\t}\n\t\t\t\t.error-icon {\n\t\t\t\t\tfont-size: 4rem;\n\t\t\t\t\tmargin-bottom: 1rem;\n\t\t\t\t}\n\t\t\t\t.error-title {\n\t\t\t\t\tfont-size: 1.5rem;\n\t\t\t\t\tfont-weight: 600;\n\t\t\t\t\tmargin-bottom: 1rem;\n\t\t\t\t\tcolor: #fff;\n\t\t\t\t}\n\t\t\t\t.error-message {\n\t\t\t\t\tfont-size: 1rem;\n\t\t\t\t\tcolor: #ccc;\n\t\t\t\t\tmargin-bottom: 2rem;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t}\n\t\t\t\t.error-actions {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tgap: 1rem;\n\t\t\t\t\tjustify-content: center;\n\t\t\t\t\tflex-wrap: wrap;\n\t\t\t\t}\n\t\t\t\t.btn {\n\t\t\t\t\tpadding: 0.75rem 1.5rem;\n\t\t\t\t\tborder-radius: 0.5rem;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-weight: 500;\n\t\t\t\t\ttransition: all 0.2s;\n\t\t\t\t\tborder: none;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\tfont-size: 0.95rem;\n\t\t\t\t}\n\t\t\t\t.btn-primary {\n\t\t\t\t\tbackground: #7aa2f7;\n\t\t\t\t\tcolor: #1a1b26;\n\t\t\t\t}\n\t\t\t\t.btn-primary:hover {\n\t\t\t\t\tbackground: #89b4fa;\n\t\t\t\t}\n\t\t\t\t.btn-secondary {\n\t\t\t\t\tbackground: transparent;\n\t\t\t\t\tcolor: #7aa2f7;\n\t\t\t\t\tborder: 1px solid #7aa2f7;\n\t\t\t\t}\n\t\t\t\t.btn-secondary:hover {\n\t\t\t\t\tbackground: rgba(122, 162, 247, 0.1);\n\t\t\t\t}\n\t\t\t</style></head><body><div class=\"error-container\"><div class=\"error-icon\">⚠️</div><h1 class=\"error-title\">Something went wrong</h1><p class=\"error-message\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 83, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 89, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+4 -6
View File
@@ -1,25 +1,23 @@
package templates package templates
// FilterItem renders a single saved filter item for the list
templ FilterItem(id string, name string) { templ FilterItem(id string, name string) {
<div <div
class="flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover" class="flex items-center justify-between p-2 rounded bg-surface hover:bg-surface-hover"
data-filter-id={ id } data-filter-id={ id }
> >
<button <button
data-action="load-filter" data-action="load-filter"
@click="loadFilter($event)" @click="loadFilter($event)"
class="flex-1 text-left px-2 py-1 text-sm rounded-lg hover:bg-surface-hover" class="flex-1 text-left px-2 py-1 rounded text-content"
style="color: var(--text-primary);"
> >
{ name } { name }
</button> </button>
<button <button
data-action="delete-filter" data-action="delete-filter"
@click="deleteFilter($event)" @click="deleteFilter($event)"
class="icon-btn shrink-0" class="icon-btn"
style="color: var(--text-secondary);"
title="Delete filter" title="Delete filter"
aria-label="Delete filter"
> >
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
</button> </button>
+5 -4
View File
@@ -8,6 +8,7 @@ package templates
import "github.com/a-h/templ" import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime" 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 { func FilterItem(id string, name string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -29,20 +30,20 @@ func FilterItem(id string, name string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover\" data-filter-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between p-2 rounded bg-surface hover:bg-surface-hover\" data-filter-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 6, Col: 21} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 7, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">") 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 text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -55,7 +56,7 @@ func FilterItem(id string, name string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn\" title=\"Delete filter\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+268 -210
View File
@@ -1,275 +1,333 @@
package templates package templates
templ Header(user User, currentPath string) { templ Header(user User, currentPath string) {
<div <nav
x-data="header" x-data="header"
x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()" x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()"
class="header-nav border-b border-line bg-surface-raised/95 backdrop-blur shadow-bar"
> >
<!-- Mobile backdrop --> <div class="w-full px-4 sm:px-6 lg:px-8">
<div <div class="flex h-16 items-center gap-4">
class="app-sidebar-backdrop lg:hidden" <!-- Brand -->
:class="{ 'is-open': mobileMenuOpen }" <a href="/dashboard" class="flex items-center gap-2 text-lg font-bold tracking-tight text-content transition-colors hover:text-brand">
@click="mobileMenuOpen = false" @Icon("book-open", "h-6 w-6 text-brand")
x-cloak <span>Bookhoard</span>
></div> <!-- Scan progress indicator -->
<!-- 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="grid place-items-center h-6 w-6 shrink-0" style="color: var(--accent);">
@Icon("book-open", "h-6 w-6")
</span>
<span class="font-bold text-lg tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
<svg <svg
x-show="scanning" x-show="scanning"
x-cloak x-cloak
x-transition class="h-4 w-4 animate-spin text-brand"
class="h-4 w-4 animate-spin ml-auto"
style="color: var(--accent);"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <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> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
<span x-show="scanning && scanProgress > 0" x-cloak x-text="scanProgress + '%'" class="text-xs font-normal text-content-muted"></span>
</a> </a>
<span
x-show="scanning && scanProgress > 0" <!-- Primary nav -->
x-cloak <div class="hidden nav:flex items-center gap-1">
x-transition <a href="/dashboard" class={ navItemClass(currentPath, "/dashboard") }>Library</a>
class="block px-5 py-1.5 text-xs font-medium -mt-1" <a href="/bookshelf" class={ navItemClass(currentPath, "/bookshelf") }>All Books</a>
style="color: var(--text-secondary);" <a href="/series" class={ navItemClass(currentPath, "/series") }>Series</a>
x-text="'Scanning… ' + scanProgress + '%'" <a href="/collections" class={ navItemClass(currentPath, "/collections") }>Collections</a>
></span> <a href="/progress" class={ navItemClass(currentPath, "/progress") }>Progress</a>
<!-- Primary navigation --> <a href="/devices" class={ navItemClass(currentPath, "/devices") }>Devices</a>
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto"> </div>
<a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }>
@Icon("library", "h-5 w-5 shrink-0") <!-- Search (center, grows) -->
<span>Library</span> <div class="hidden nav:flex flex-1 max-w-xl mx-auto">
</a> <div class="relative w-full">
<a href="/bookshelf" class={ activeClass(currentPath, "/bookshelf") }> <div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
@Icon("book", "h-5 w-5 shrink-0") @Icon("search", "h-4 w-4")
<span>All Books</span> </div>
</a> <input
<a href="/series" class={ activeClass(currentPath, "/series") }> type="text"
@Icon("layers", "h-5 w-5 shrink-0") id="header-search"
<span>Series</span> placeholder="Search your library…"
</a> class="input pl-9"
<a href="/collections" class={ activeClass(currentPath, "/collections") }> autocomplete="off"
@Icon("folder", "h-5 w-5 shrink-0") />
<span>Collections</span> </div>
</a> </div>
<a href="/progress" class={ activeClass(currentPath, "/progress") }>
@Icon("clock", "h-5 w-5 shrink-0") <!-- Right cluster -->
<span>Progress</span> <div class="hidden nav:flex items-center gap-1">
</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
x-data="{ openPanel: window.location.pathname.startsWith('/admin') ? 'admin' : null }"
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 --> <!-- Theme picker -->
<div class="sidebar-panel"> <div class="relative" x-data="{ themeDropdownOpen: false }">
<button <button @click="themeDropdownOpen = !themeDropdownOpen" type="button" class="icon-btn" aria-label="Change theme">
type="button" @Icon("palette", "h-5 w-5")
@click="openPanel = openPanel === 'appearance' ? null : 'appearance'"
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>
<span class="ml-auto h-4 w-4 transition-transform" :class="{ 'rotate-180': openPanel === 'appearance' }">
@Icon("chevron-down", "h-4 w-4")
</span>
</button> </button>
<div x-show="openPanel === 'appearance'" x-cloak x-transition class="mt-1 space-y-0.5 pl-1"> <div
for _, opt := range ThemeOptions { x-show="themeDropdownOpen"
<button x-cloak
type="button" @click.outside="themeDropdownOpen = false"
@click={ "changeTheme('" + opt.Name + "'); openPanel = null" } x-transition:enter="transition ease-out duration-150"
class="theme-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" x-transition:enter-start="opacity-0 -translate-y-1"
data-theme={ opt.Name } x-transition:enter-end="opacity-100 translate-y-0"
style="color: var(--text-secondary);" x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
class="absolute right-0 mt-2 w-72 rounded-xl border border-line bg-surface-raised p-3 shadow-pop"
style="display: none;"
> >
<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> <p class="px-1 pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">Theme</p>
<span class="flex-1 text-left">{ opt.Label }</span> <div class="grid grid-cols-1 gap-1">
<span class={ "theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme) }> for _, t := range ThemeOptions {
@Icon("check", "h-4 w-4") <button
</span> @click={ "changeTheme('" + t.Name + "'); themeDropdownOpen = false" }
type="button"
class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-content transition-colors hover:bg-surface-hover"
if t.Name == user.Theme {
class="flex w-full items-center gap-3 rounded-lg bg-surface-hover px-2 py-2 text-sm font-medium text-content"
}
>
<span class="h-4 w-4 shrink-0 rounded-full border border-line" style={ "background-color: " + t.Color }></span>
<span class="flex-1 text-left">{ t.Label }</span>
if t.Name == user.Theme {
@Icon("check", "h-4 w-4 text-brand")
}
</button> </button>
} }
<div class="my-1.5 mx-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"></div> </div>
<p class="px-3 pb-1 text-xs font-medium uppercase tracking-wide" style="color: var(--text-secondary);">Bookshelf</p> <div class="mt-3 border-t border-line pt-3">
<p class="px-1 pb-2 text-xs font-semibold uppercase tracking-wide text-content-muted">Bookshelf background</p>
<div class="grid grid-cols-1 gap-1">
for _, w := range WoodOptions { for _, w := range WoodOptions {
<button <button
@click={ "changeWoodPaneling('" + w.Name + "'); themeDropdownOpen = false" }
type="button" type="button"
@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 } data-wood={ w.Name }
style="color: var(--text-secondary);" class="wood-paneling-btn flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-content transition-colors hover:bg-surface-hover"
> >
if w.Name == "none" { if w.Name == "none" {
<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="h-4 w-4 shrink-0 rounded-full border border-line bg-surface"></span>
} else { } else {
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;" }></span> <span
class="h-4 w-4 shrink-0 rounded-full border border-line"
style={ "background-image: url('/static/textures/" + w.Name + ".png'); background-size: cover;" }
></span>
} }
<span class="flex-1 text-left">{ w.Label }</span> <span class="flex-1 text-left">{ w.Label }</span>
</button> </button>
} }
</div> </div>
</div> </div>
<!-- Admin section (visible to admins only) -->
if user.Role == "admin" {
<div class="sidebar-panel">
<button
type="button"
@click="openPanel = openPanel === 'admin' ? null : 'admin'"
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("shield", "h-5 w-5 shrink-0")
<span>Administration</span>
<svg
class="h-4 w-4 ml-auto transition-transform"
:class="{ 'rotate-180': openPanel === 'admin' }"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div x-show="openPanel === 'admin'" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
<a href="/admin" class={ activeClass(currentPath, "/admin") }>
@Icon("grid", "h-5 w-5 shrink-0")
<span>Dashboard</span>
</a>
<a href="/admin/library" class={ activeClass(currentPath, "/admin/library") }>
@Icon("library", "h-5 w-5 shrink-0")
<span>Libraries</span>
</a>
<a href="/admin/users" class={ activeClass(currentPath, "/admin/users") }>
@Icon("users", "h-5 w-5 shrink-0")
<span>Users</span>
</a>
<a href="/admin/settings" class={ activeClass(currentPath, "/admin/settings") }>
@Icon("gear", "h-5 w-5 shrink-0")
<span>Settings</span>
</a>
</div> </div>
</div> </div>
<!-- User menu / login -->
<div class="relative" x-data="{ userMenuOpen: false }">
if user.ID != "" {
<button @click="userMenuOpen = !userMenuOpen" type="button" class="flex items-center gap-2 rounded-lg p-1.5 pr-2 text-content transition-colors hover:bg-surface-hover" aria-label="Account menu">
<span class="flex h-8 w-8 items-center justify-center rounded-full bg-brand/15 text-brand">
@Icon("user", "h-5 w-5")
</span>
<span class="hidden sm:block text-sm font-medium">{ user.Username }</span>
</button>
<div
x-show="userMenuOpen"
x-cloak
@click.outside="userMenuOpen = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
class="absolute right-0 mt-2 w-52 rounded-xl border border-line bg-surface-raised p-1.5 shadow-pop"
style="display: none;"
>
<div class="px-3 py-2">
<p class="text-sm font-semibold text-content">{ user.Username }</p>
if user.Email != "" {
<p class="truncate text-xs text-content-muted">{ user.Email }</p>
}
</div>
<div class="my-1 border-t border-line"></div>
<a href="/profile" class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@Icon("user", "h-4 w-4 text-content-muted")
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@Icon("shield", "h-4 w-4 text-content-muted")
Admin Panel
</a>
}
<div class="my-1 border-t border-line"></div>
<button @click="logout()" type="button" class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-danger transition-colors hover:bg-danger/10">
@Icon("logout", "h-4 w-4")
Logout
</button>
</div>
} else {
<button @click="userMenuOpen = !userMenuOpen" type="button" class="flex items-center gap-2 rounded-lg p-1.5 pr-2 text-content transition-colors hover:bg-surface-hover" aria-label="Login">
<span class="flex h-8 w-8 items-center justify-center rounded-full bg-brand/15 text-brand">
@Icon("user", "h-5 w-5")
</span>
<span class="hidden sm:block text-sm font-medium">Login</span>
</button>
<div
x-show="userMenuOpen"
x-cloak
@click.outside="userMenuOpen = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
class="absolute right-0 mt-2 w-72 rounded-xl border border-line bg-surface-raised p-4 shadow-pop"
style="display: none;"
>
@HeaderLoginForm("", currentPath)
</div>
} }
</div> </div>
</aside> </div>
<!-- Topbar -->
<header class="app-topbar"> <!-- Mobile menu toggle -->
<div class="flex items-center gap-3 px-4 sm:px-6 h-full">
<button <button
@click="mobileMenuOpen = !mobileMenuOpen"
type="button" type="button"
class="app-mobile-only icon-btn" class="icon-btn nav:hidden"
@click="mobileMenuOpen = true" aria-label="Toggle menu"
aria-label="Open menu"
> >
@Icon("menu", "h-5 w-5") @Icon("menu", "h-5 w-5")
</button> </button>
<div class="relative flex-1 max-w-xl"> </div>
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);"> </div>
<!-- Mobile drawer -->
<div
x-show="mobileMenuOpen"
x-cloak
@click.outside="mobileMenuOpen = false"
x-data="{ mobileThemeOpen: 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 border-line bg-surface-raised"
style="display: none;"
>
<div class="space-y-1 px-4 py-4">
<!-- Mobile search -->
<div class="relative mb-3">
<div class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-content-muted">
@Icon("search", "h-4 w-4") @Icon("search", "h-4 w-4")
</span> </div>
<input <input
type="text" type="text"
id="header-search" id="header-search-mobile"
placeholder="Search your library…" placeholder="Search your library…"
class="input pl-10" class="input pl-9"
autocomplete="off" autocomplete="off"
/> />
</div> </div>
</div>
</header>
<script type="module" src="/static/main.js"></script>
</div>
}
// SidebarUserMenu is the bottom-of-sidebar account control for signed-in users. <a href="/dashboard" class={ navItemClass(currentPath, "/dashboard") }>@Icon("library", "h-4 w-4") Library</a>
templ SidebarUserMenu(user User) { <a href="/bookshelf" class={ navItemClass(currentPath, "/bookshelf") }>@Icon("book", "h-4 w-4") All Books</a>
<div class="sidebar-panel"> <a href="/series" class={ navItemClass(currentPath, "/series") }>@Icon("layers", "h-4 w-4") Series</a>
<button <a href="/collections" class={ navItemClass(currentPath, "/collections") }>@Icon("folder", "h-4 w-4") Collections</a>
type="button" <a href="/progress" class={ navItemClass(currentPath, "/progress") }>@Icon("clock", "h-4 w-4") Progress</a>
@click="openPanel = openPanel === 'user' ? null : 'user'" <a href="/devices" class={ navItemClass(currentPath, "/devices") }>@Icon("device", "h-4 w-4") Devices</a>
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);" <div class="my-2 border-t border-line"></div>
>
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);"> <!-- Theme accordion -->
@Icon("user", "h-4 w-4") <button @click="mobileThemeOpen = !mobileThemeOpen" type="button" class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
</span> @Icon("palette", "h-4 w-4 text-content-muted")
<span class="flex-1 text-left truncate">{ user.Username }</span> <span>Theme</span>
<span class="h-4 w-4 shrink-0 transition-transform" :class="{ 'rotate-180': openPanel === 'user' }"> <svg class={ "ml-auto h-4 w-4 transition-transform " } :class="{ 'rotate-180': mobileThemeOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@Icon("chevron-down", "h-4 w-4") <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</span> </svg>
</button> </button>
<div x-show="openPanel === 'user'" x-cloak x-transition class="mt-1 space-y-0.5 pl-1"> <div x-show="mobileThemeOpen" x-cloak x-transition class="space-y-1 pl-3">
<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);"> for _, t := range ThemeOptions {
@Icon("user", "h-4 w-4") <button
<span>Profile</span> @click={ "changeTheme('" + t.Name + "'); mobileThemeOpen = false" }
type="button"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover"
>
<span class="h-4 w-4 rounded-full border border-line" style={ "background-color: " + t.Color }></span>
{ t.Label }
</button>
}
<div class="my-1 border-t border-line"></div>
for _, w := range WoodOptions {
<button
@click={ "changeWoodPaneling('" + w.Name + "'); mobileThemeOpen = false" }
type="button"
data-wood={ w.Name }
class="wood-paneling-btn flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover"
>
if w.Name == "none" {
<span class="h-4 w-4 rounded-full border border-line bg-surface"></span>
} else {
<span class="h-4 w-4 rounded-full border border-line" style={ "background-image: url('/static/textures/" + w.Name + ".png'); background-size: cover;" }></span>
}
{ w.Label }
</button>
}
</div>
<div class="my-2 border-t border-line"></div>
if user.ID != "" {
<a href="/profile" class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@Icon("user", "h-4 w-4 text-content-muted")
Profile
</a> </a>
<button if user.Role == "admin" {
type="button" <a href="/admin" class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-content transition-colors hover:bg-surface-hover">
@click="logout()" @Icon("shield", "h-4 w-4 text-content-muted")
class="w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" Admin Panel
style="color: var(--text-secondary);" </a>
> }
<button @click="logout()" type="button" class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-danger transition-colors hover:bg-danger/10">
@Icon("logout", "h-4 w-4") @Icon("logout", "h-4 w-4")
<span>Logout</span> Logout
</button> </button>
</div> } else {
<div class="rounded-xl border border-line bg-surface p-4">
@HeaderLoginForm("-mobile", currentPath)
</div> </div>
} }
</div>
</div>
</nav>
<script type="module" src="/static/main.js"></script>
}
// SidebarSignIn is an inline sign-in for signed-out visitors, preserving the // HeaderLoginForm renders the compact inline login form used in the header
// original header's inline login (htmx POST) so users can auth from any page. // dropdown (desktop) and mobile drawer. suffix is appended to element ids so
templ SidebarSignIn(currentPath string) { // the two instances do not collide.
<div class="sidebar-panel"> templ HeaderLoginForm(suffix string, currentPath string) {
<button
type="button"
@click="openPanel = openPanel === 'signin' ? null : 'signin'"
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>
<span class="h-4 w-4 shrink-0 transition-transform" :class="{ 'rotate-180': openPanel === 'signin' }">
@Icon("chevron-down", "h-4 w-4")
</span>
</button>
<div x-show="openPanel === 'signin'" x-cloak x-transition class="mt-1 px-1">
<form <form
hx-post="/api/auth/login" hx-post="/api/auth/login"
hx-target="#login-result" hx-target={ "#login-result" + suffix }
hx-swap="innerHTML" hx-swap="innerHTML"
class="space-y-2" class="space-y-3"
> >
<input type="hidden" name="redirect" value={ currentPath }/> <input type="hidden" name="redirect" value={ currentPath }/>
<input type="text" name="login" class="input py-1.5 text-sm" placeholder="Email or username" required/> <div>
<input type="password" name="password" class="input py-1.5 text-sm" placeholder="Password" required/> <label class="mb-1 block text-xs font-medium text-content-muted">Email or Username</label>
<button type="submit" class="btn btn-primary w-full py-1.5 text-sm">Sign In</button> <input type="text" name="login" class="input" required/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-content-muted">Password</label>
<input type="password" name="password" class="input" required/>
</div>
<button type="submit" class="btn btn-primary w-full">Sign In</button>
</form> </form>
<div id="login-result"></div> <div id={ "login-result" + suffix }></div>
<a href="/register" class="block text-center text-xs mt-2 hover:underline" style="color: var(--text-secondary);"> <div class="my-3 border-t border-line"></div>
Create an account <a href="/register" class="block text-center text-sm text-content-muted transition-colors hover:text-content">
Don't have an account? Sign Up
</a> </a>
</div>
</div>
} }
+486 -263
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -50,9 +50,6 @@ templ Icon(name string, extra string) {
case "settings": case "settings":
<circle cx="12" cy="12" r="3"></circle> <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> <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": case "refresh":
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path> <path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M21 3v5h-5"></path> <path d="M21 3v5h-5"></path>
+42 -47
View File
@@ -126,218 +126,213 @@ func Icon(name string, extra string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "book": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "book-open": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "library": case "library":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<path d=\"M3 21h18\"></path> <path d=\"M5 21V7l7-4 7 4v14\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<path d=\"M3 21h18\"></path> <path d=\"M5 21V7l7-4 7 4v14\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "layers": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "folder": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "chart": case "chart":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "device": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "trash": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "save": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "filter": case "filter":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "check": case "check":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"m4 12 5 5 11-11\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"m4 12 5 5 11-11\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "check-circle": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "x-circle": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "circle": case "circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "bookmark": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "star": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "arrow-right": case "arrow-right":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "arrow-left": case "arrow-left":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "home": case "home":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "logout": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "plus": case "plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M12 5v14M5 12h14\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M12 5v14M5 12h14\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "edit": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "info": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "alert": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "clock": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "sync": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "download": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "upload": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "copy": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "external": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "list": case "list":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "grid": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "play": case "play":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M6 4l14 8-14 8z\"></path>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<path d=\"M6 4l14 8-14 8z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "heart": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "calendar": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "tag": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "globe": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "shield": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
case "database": 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
default: default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</svg>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</svg>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+50 -61
View File
@@ -10,100 +10,89 @@ templ Index(loggedIn bool) {
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script type="module" src="/static/main.js"></script> <script type="module" src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-tokyo-night" x-data="index" x-init="initIndexTheme(); checkAuthRedirect()"> <body class="theme-tokyo-night" x-data="index" x-init="initIndexTheme(); checkAuthRedirect()">
<div class="absolute top-4 right-4 z-10"> <!-- Hero -->
<select <div class="relative overflow-hidden">
id="theme-select" <div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-brand/15 via-transparent to-brand/5"></div>
@change="changeTheme" <div class="relative mx-auto max-w-5xl px-4 py-20 sm:px-6 lg:px-8 lg:py-28">
class="input w-auto py-1.5 pr-8 text-sm cursor-pointer" <div class="absolute right-4 top-4">
aria-label="Theme" <select id="theme-select" @change="changeTheme" class="input w-auto py-1.5 text-xs">
> for _, t := range ThemeOptions {
for _, opt := range ThemeOptions { <option value={ t.Name }>{ t.Label }</option>
<option value={ opt.Name }>{ opt.Label }</option>
} }
</select> </select>
</div> </div>
<!-- Hero --> <div class="text-center">
<div class="brand-gradient relative overflow-hidden"> <div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-brand/15 text-brand">
<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"> @Icon("book-open", "h-9 w-9")
<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>
<p class="mt-4 max-w-2xl mx-auto text-lg sm:text-xl" style="color: var(--text-secondary)"> <h1 class="text-4xl font-extrabold tracking-tight text-content sm:text-6xl">
Your personal ebook management system. Track reading progress, organize your library, and sync across every device. Your books, beautifully organized.
</h1>
<p class="mx-auto mt-5 max-w-2xl text-lg text-content-muted">
A self-hosted ebook and comic library with cross-device sync, a built-in reader, and reading analytics.
</p> </p>
<div class="mt-10 flex items-center justify-center gap-3"> <div class="mt-8 flex justify-center">
if loggedIn { <a href="#auth" class="btn btn-primary px-8 py-3 text-base">
<a href="/dashboard" class="btn btn-primary px-8 py-3 text-base">Go to Library</a> @Icon("arrow-right", "h-4 w-4")
} else { Get Started
<a href="#auth" class="btn btn-primary px-8 py-3 text-base">Get Started</a> </a>
<a href="#features" class="btn btn-secondary px-6 py-3 text-base">Learn more</a>
}
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Features --> <!-- Features -->
<div id="features" class="py-16 sm:py-20"> <div class="border-t border-line bg-surface-raised">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-5xl px-4 py-16 sm:px-6 lg:px-8">
<div class="text-center mb-12"> <div class="mb-10 text-center">
<h2 class="text-3xl font-extrabold tracking-tight" style="color: var(--text-primary)">Why Bookhoard?</h2> <h2 class="text-2xl font-bold tracking-tight text-content">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>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 gap-5 md:grid-cols-3">
<div class="card p-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);"> <div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand/15 text-brand">
@Icon("clock", "h-6 w-6")
</div>
<h3 class="mb-1.5 text-base font-semibold text-content">Cross-device sync</h3>
<p class="text-sm leading-relaxed text-content-muted">Sync reading progress, bookmarks, and highlights across KOReader, Kobo, and the web.</p>
</div>
<div class="card p-6">
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand/15 text-brand">
@Icon("book-open", "h-6 w-6") @Icon("book-open", "h-6 w-6")
</div> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3> <h3 class="mb-1.5 text-base font-semibold text-content">Built-in reader</h3>
<p class="text-sm" style="color: var(--text-secondary)">Track progress across all your ebooks with detailed statistics and sync.</p> <p class="text-sm leading-relaxed text-content-muted">Read EPUB, PDF, and comics in the browser with custom themes, fonts, and layouts.</p>
</div> </div>
<div class="card p-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);"> <div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand/15 text-brand">
@Icon("palette", "h-6 w-6") @Icon("chart", "h-6 w-6")
</div> </div>
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3> <h3 class="mb-1.5 text-base font-semibold text-content">Smart collections</h3>
<p class="text-sm" style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin.</p> <p class="text-sm leading-relaxed text-content-muted">Organize with rules-based collections, series tracking, and reading analytics.</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);">
@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>
</div> </div>
</div> </div>
<!-- Auth --> <!-- Auth -->
if !loggedIn { if !loggedIn {
<div id="auth" class="pb-20 px-4"> <div id="auth" class="mx-auto max-w-md px-4 py-16 sm:px-6 lg:px-8">
<div class="max-w-md mx-auto"> <div class="card p-6">
<div class="text-center mb-8"> <h2 class="mb-5 text-center text-lg font-bold text-content">Sign in to Bookhoard</h2>
<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"> <form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML" class="space-y-4">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email or Username</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Email or Username</label>
<input type="text" name="login" class="input" required/> <input type="text" name="login" class="input" required/>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Password</label>
<input type="password" name="password" class="input" required/> <input type="password" name="password" class="input" required/>
</div> </div>
<button type="submit" class="btn btn-primary w-full py-2.5">Sign In</button> <button type="submit" class="btn btn-primary w-full">Sign In</button>
</form> </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>
<div id="auth-result" class="mt-6 text-center"></div>
</div> </div>
} }
</body> </body>
+26 -25
View File
@@ -29,19 +29,19 @@ func Index(loggedIn bool) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>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\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></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\">") 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 --><div class=\"relative overflow-hidden\"><div class=\"pointer-events-none absolute inset-0 bg-gradient-to-br from-brand/15 via-transparent to-brand/5\"></div><div class=\"relative mx-auto max-w-5xl px-4 py-20 sm:px-6 lg:px-8 lg:py-28\"><div class=\"absolute right-4 top-4\"><select id=\"theme-select\" @change=\"changeTheme\" class=\"input w-auto py-1.5 text-xs\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, opt := range ThemeOptions { for _, t := range ThemeOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 22, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -52,9 +52,9 @@ func Index(loggedIn bool) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(t.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 22, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -65,22 +65,31 @@ func Index(loggedIn bool) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</select></div><div class=\"text-center\"><div class=\"mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-brand/15 text-brand\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if loggedIn { templ_7745c5c3_Err = Icon("book-open", "h-9 w-9").Render(ctx, templ_7745c5c3_Buffer)
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><h1 class=\"text-4xl font-extrabold tracking-tight text-content sm:text-6xl\">Your books, beautifully organized.</h1><p class=\"mx-auto mt-5 max-w-2xl text-lg text-content-muted\">A self-hosted ebook and comic library with cross-device sync, a built-in reader, and reading analytics.</p><div class=\"mt-8 flex justify-center\"><a href=\"#auth\" class=\"btn btn-primary px-8 py-3 text-base\">")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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, 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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Get Started</a></div></div></div></div><!-- Features --><div class=\"border-t border-line bg-surface-raised\"><div class=\"mx-auto max-w-5xl px-4 py-16 sm:px-6 lg:px-8\"><div class=\"mb-10 text-center\"><h2 class=\"text-2xl font-bold tracking-tight text-content\">Why Bookhoard?</h2></div><div class=\"grid grid-cols-1 gap-5 md:grid-cols-3\"><div class=\"card p-6\"><div class=\"mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand/15 text-brand\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("clock", "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, 8, "</div><h3 class=\"mb-1.5 text-base font-semibold text-content\">Cross-device sync</h3><p class=\"text-sm leading-relaxed text-content-muted\">Sync reading progress, bookmarks, and highlights across KOReader, Kobo, and the web.</p></div><div class=\"card p-6\"><div class=\"mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand/15 text-brand\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -88,33 +97,25 @@ func Index(loggedIn bool) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><h3 class=\"mb-1.5 text-base font-semibold text-content\">Built-in reader</h3><p class=\"text-sm leading-relaxed text-content-muted\">Read EPUB, PDF, and comics in the browser with custom themes, fonts, and layouts.</p></div><div class=\"card p-6\"><div class=\"mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-brand/15 text-brand\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("palette", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("chart", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><h3 class=\"mb-1.5 text-base font-semibold text-content\">Smart collections</h3><p class=\"text-sm leading-relaxed text-content-muted\">Organize with rules-based collections, series tracking, and reading analytics.</p></div></div></div></div><!-- Auth -->")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if !loggedIn { 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div id=\"auth\" class=\"mx-auto max-w-md px-4 py-16 sm:px-6 lg:px-8\"><div class=\"card p-6\"><h2 class=\"mb-5 text-center text-lg font-bold text-content\">Sign in to Bookhoard</h2><form hx-post=\"/api/auth/login\" hx-target=\"#auth-result\" hx-swap=\"innerHTML\" class=\"space-y-4\"><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full\">Sign In</button></form></div><div id=\"auth-result\" class=\"mt-6 text-center\"></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+12 -13
View File
@@ -1,17 +1,14 @@
package templates package templates
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...templ.Component) { templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...templ.Component) {
<div <div class="sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur">
class="app-subbar backdrop-blur border-b" <div class="w-full px-4 sm:px-6 lg:px-8 py-2.5 flex items-center justify-between gap-4">
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="flex items-center gap-3 min-w-0">
> <label class="hidden sm:block text-xs font-semibold uppercase tracking-wide text-content-muted" for="library-select">Library</label>
<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 <select
id="library-select" id="library-select"
name="library_id" name="library_id"
class="input w-auto py-1.5 pr-8 cursor-pointer" class="input w-auto min-w-[12rem] py-1.5"
> >
<option value="">All Libraries ({ TotalMediaCount(libData) })</option> <option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData { for _, lib := range libData {
@@ -24,7 +21,7 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
</select> </select>
</div> </div>
if len(actions) > 0 { if len(actions) > 0 {
<div class="flex items-center gap-2"> <div class="flex items-center gap-1">
for _, action := range actions { for _, action := range actions {
@action @action
} }
@@ -33,10 +30,10 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
</div> </div>
<div <div
id="loading-spinner" id="loading-spinner"
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: var(--bg-primary);" style="background-color: var(--surface-overlay);"
> >
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
} }
@@ -46,13 +43,15 @@ templ DashboardActions() {
data-action="open-dashboard-settings" data-action="open-dashboard-settings"
class="icon-btn" class="icon-btn"
title="Customize Dashboard" title="Customize Dashboard"
aria-label="Customize Dashboard"
> >
@Icon("grid", "h-5 w-5") @Icon("settings", "h-5 w-5")
</button> </button>
<button <button
data-action="reload-page" data-action="reload-page"
class="icon-btn" class="icon-btn"
title="Refresh" title="Refresh"
aria-label="Refresh"
> >
@Icon("refresh", "h-5 w-5") @Icon("refresh", "h-5 w-5")
</button> </button>
+13 -13
View File
@@ -29,14 +29,14 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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 (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-16 z-30 border-b border-line bg-surface/95 backdrop-blur\"><div class=\"w-full px-4 sm:px-6 lg:px-8 py-2.5 flex items-center justify-between gap-4\"><div class=\"flex items-center gap-3 min-w-0\"><label class=\"hidden sm:block text-xs font-semibold uppercase tracking-wide text-content-muted\" for=\"library-select\">Library</label> <select id=\"library-select\" name=\"library_id\" class=\"input w-auto min-w-[12rem] py-1.5\"><option value=\"\">All Libraries (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 13, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -55,7 +55,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -68,7 +68,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -81,7 +81,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -99,7 +99,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -112,7 +112,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -125,7 +125,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 18, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -142,7 +142,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(actions) > 0 { if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-1\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -157,7 +157,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: var(--surface-overlay);\"><div class=\"loading-spinner\"></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -186,15 +186,15 @@ func DashboardActions() templ.Component {
templ_7745c5c3_Var9 = templ.NopComponent templ_7745c5c3_Var9 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\" aria-label=\"Customize Dashboard\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("grid", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("settings", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\" aria-label=\"Refresh\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+26 -30
View File
@@ -10,53 +10,49 @@ templ Login(sessionExpired bool, deleted bool) {
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script type="module" src="/static/main.js"></script> <script type="module" src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-tokyo-night min-h-screen" x-data="login" x-init="initLoginTheme()"> <body class="theme-tokyo-night flex min-h-screen items-center justify-center px-4" x-data="login" x-init="initLoginTheme()">
<div class="brand-gradient min-h-screen flex flex-col items-center justify-center p-4 relative"> <div class="w-full max-w-md">
<select <div class="mb-8 flex items-center justify-center gap-2 text-2xl font-bold tracking-tight text-content">
id="theme-select" @Icon("book-open", "h-7 w-7 text-brand")
@change="changeTheme()" Bookhoard
class="input absolute top-4 right-4 w-auto py-1.5 pr-8 text-sm cursor-pointer" </div>
aria-label="Theme" <div class="mb-6 flex justify-end">
> <select id="theme-select" @change="changeTheme()" class="input w-auto py-1.5 text-xs">
for _, opt := range ThemeOptions { for _, t := range ThemeOptions {
<option value={ opt.Name }>{ opt.Label }</option> <option value={ t.Name }>{ t.Label }</option>
} }
</select> </select>
<div class="w-full max-w-sm"> </div>
<a href="/" class="flex items-center justify-center gap-2.5 mb-8"> <div class="card p-6">
<span class="text-3xl">📚</span> <h1 class="mb-5 text-xl font-bold text-content">Welcome back</h1>
<span class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
</a>
if sessionExpired { 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);"> <div class="mb-4 flex items-start gap-2 rounded-lg border border-brand/40 bg-brand/10 p-3 text-sm text-content">
@Icon("info", "h-5 w-5 shrink-0") @Icon("info", "h-4 w-4 mt-0.5 shrink-0 text-brand")
<p class="text-sm" style="color: var(--text-primary)">Your session has expired. Please log in again to continue.</p> <span>Your session has expired. Please log in again to continue.</span>
</div> </div>
} }
if deleted { 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);"> <div class="mb-4 flex items-start gap-2 rounded-lg border border-success/40 bg-success/10 p-3 text-sm text-content">
@Icon("check-circle", "h-5 w-5 shrink-0") @Icon("check-circle", "h-4 w-4 mt-0.5 shrink-0 text-success")
<p class="text-sm" style="color: var(--text-primary)">Your account has been deleted successfully.</p> <span>Your account has been deleted successfully.</span>
</div> </div>
} }
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 space-y-4"> <form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="space-y-4">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email or Username</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Email or Username</label>
<input type="text" name="login" class="input" required/> <input type="text" name="login" class="input" required/>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Password</label>
<input type="password" name="password" class="input" required/> <input type="password" name="password" class="input" required/>
</div> </div>
<button type="submit" class="btn btn-primary w-full py-2.5">Sign In</button> <button type="submit" class="btn btn-primary w-full">Sign In</button>
</form> </form>
<div id="result" class="mt-4 text-center text-sm"></div> <div id="result" class="mt-4 text-center text-sm"></div>
<p class="text-center mt-6 text-sm" style="color: var(--text-secondary)"> <div class="mt-4 border-t border-line pt-4 text-center">
Don't have an account? <a href="/register" class="text-sm text-content-muted transition-colors hover:text-content">Don't have an account? Sign Up</a>
<a href="/register" class="font-medium hover:underline" style="color: var(--accent)">Sign up</a> </div>
</p>
</div> </div>
</div> </div>
</body> </body>
+25 -17
View File
@@ -29,75 +29,83 @@ func Login(sessionExpired bool, deleted bool) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>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\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></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\">") 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 flex min-h-screen items-center justify-center px-4\" x-data=\"login\" x-init=\"initLoginTheme()\"><div class=\"w-full max-w-md\"><div class=\"mb-8 flex items-center justify-center gap-2 text-2xl font-bold tracking-tight text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, opt := range ThemeOptions { templ_7745c5c3_Err = Icon("book-open", "h-7 w-7 text-brand").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"") if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Bookhoard</div><div class=\"mb-6 flex justify-end\"><select id=\"theme-select\" @change=\"changeTheme()\" class=\"input w-auto py-1.5 text-xs\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, t := range ThemeOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 23, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(t.Label)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 23, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</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> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</select></div><div class=\"card p-6\"><h1 class=\"mb-5 text-xl font-bold text-content\">Welcome back</h1>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if sessionExpired { if sessionExpired {
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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"mb-4 flex items-start gap-2 rounded-lg border border-brand/40 bg-brand/10 p-3 text-sm text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("info", "h-4 w-4 mt-0.5 shrink-0 text-brand").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" 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, 8, "<span>Your session has expired. Please log in again to continue.</span></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if deleted { if deleted {
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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"mb-4 flex items-start gap-2 rounded-lg border border-success/40 bg-success/10 p-3 text-sm text-content\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("check-circle", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("check-circle", "h-4 w-4 mt-0.5 shrink-0 text-success").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your account has been deleted successfully.</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span>Your account has been deleted successfully.</span></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"space-y-4\"><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center text-sm\"></div><div class=\"mt-4 border-t border-line pt-4 text-center\"><a href=\"/register\" class=\"text-sm text-content-muted transition-colors hover:text-content\">Don't have an account? Sign Up</a></div></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+10 -22
View File
@@ -8,36 +8,24 @@ templ Profile(user User) {
<title>Profile Settings - Bookhoard</title> <title>Profile Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class="theme-{ user.Theme }" x-data="profile"> <body class="theme-{ user.Theme }" x-data="profile">
@Header(user, "/profile") @Header(user, "/profile")
<main class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8"> <main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-8 flex items-center gap-3"> <div class="mb-8">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);"> <h1 class="text-2xl font-bold tracking-tight mb-2 text-content">Profile Settings</h1>
@Icon("user", "h-5 w-5") <p class="text-content-muted">Manage your account information and preferences</p>
</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> <div class="space-y-8">
<div class="space-y-6"> <!-- Account Information & Password - uses reusable ProfileForm -->
<div class="card p-6"> <div class="card p-6">
@ProfileForm(user, "/api/auth/profile", true, false, false) @ProfileForm(user, "/api/auth/profile", true, false, false)
</div> </div>
<!-- Danger Zone -->
<div class="card p-6"> <div class="card p-6">
<div class="flex items-start gap-3 mb-4"> <h3 class="text-xl font-semibold mb-4 text-content">Danger Zone</h3>
<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);"> <p class="text-content-muted mb-4">Once you delete your account, there is no going back. Please be certain.</p>
@Icon("alert", "h-5 w-5") <button @click="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;">
</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 Remove My Account
</button> </button>
</div> </div>
+22 -26
View File
@@ -6,16 +6,14 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
hx-target="#profile-result" hx-target="#profile-result"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
class="space-y-8" class="space-y-6"
> >
<!-- Account Information -->
<div> <div>
<div class="flex items-center gap-2 mb-4"> <h3 class="text-lg font-semibold mb-4 text-content">Account Information</h3>
@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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Username</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Username</label>
<input <input
type="text" type="text"
name="username" name="username"
@@ -24,7 +22,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Email Address</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Email Address</label>
<input <input
type="email" type="email"
name="email" name="email"
@@ -33,7 +31,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">First Name</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">First Name</label>
<input <input
type="text" type="text"
name="first_name" name="first_name"
@@ -43,7 +41,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Last Name</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Last Name</label>
<input <input
type="text" type="text"
name="last_name" name="last_name"
@@ -53,7 +51,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Theme</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Theme</label>
<select <select
name="theme" name="theme"
class="input" class="input"
@@ -68,7 +66,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Timezone</label>
<select <select
name="timezone" name="timezone"
class="input" class="input"
@@ -101,7 +99,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
</div> </div>
if showRoleField { if showRoleField {
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Role</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Role</label>
<select <select
name="role" name="role"
class="input" class="input"
@@ -113,15 +111,14 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
} }
</div> </div>
</div> </div>
<!-- Change Password Section -->
<div> <div>
<div class="flex items-center gap-2 mb-4"> <h3 class="text-lg font-semibold mb-4 text-content">Change Password</h3>
@Icon("shield", "h-5 w-5")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Change Password</h3>
</div>
if requireCurrentPassword { if requireCurrentPassword {
<!-- Self-edit: require current password -->
<div class="space-y-4 max-w-md"> <div class="space-y-4 max-w-md">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Current Password</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Current Password</label>
<input <input
type="password" type="password"
name="current_password" name="current_password"
@@ -130,7 +127,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">New Password</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">New Password</label>
<input <input
type="password" type="password"
name="new_password" name="new_password"
@@ -139,7 +136,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Confirm New Password</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Confirm New Password</label>
<input <input
type="password" type="password"
name="confirm_password" name="confirm_password"
@@ -156,18 +153,18 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
hx-include="closest form" hx-include="closest form"
class="btn btn-primary" class="btn btn-primary"
> >
@Icon("save", "h-4 w-4")
Update Password Update Password
</button> </button>
<div id="password-result" class="mt-2"></div> <div id="password-result" class="mt-2"></div>
</div> </div>
} else { } else {
<p class="text-sm italic mb-4" style="color: var(--text-secondary);"> <!-- Admin edit: no current password required -->
<p class="text-sm italic mb-4 text-content-muted">
As an admin, you can change this user's password without knowing their current password. As an admin, you can change this user's password without knowing their current password.
</p> </p>
<div class="space-y-4 max-w-md"> <div class="space-y-4 max-w-md">
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">New Password</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">New Password</label>
<input <input
type="password" type="password"
name="new_password" name="new_password"
@@ -176,7 +173,7 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Confirm New Password</label> <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted">Confirm New Password</label>
<input <input
type="password" type="password"
name="confirm_password" name="confirm_password"
@@ -193,14 +190,14 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
hx-include="closest form" hx-include="closest form"
class="btn btn-primary" class="btn btn-primary"
> >
@Icon("save", "h-4 w-4")
Reset Password Reset Password
</button> </button>
<div id="password-result" class="mt-2"></div> <div id="password-result" class="mt-2"></div>
</div> </div>
} }
</div> </div>
<div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);"> <!-- Actions -->
<div class="flex justify-end gap-3 pt-6 border-t border-line">
if showCancelButton { if showCancelButton {
<button <button
type="button" type="button"
@@ -214,7 +211,6 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
> >
@Icon("save", "h-4 w-4")
Save Changes Save Changes
</button> </button>
</div> </div>
+84 -124
View File
@@ -42,477 +42,437 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" 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\">") 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 text-content\">Account Information</h3><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Username</label> <input type=\"text\" name=\"username\" value=\"")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 22, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 20, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"input\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Email Address</label> <input type=\"email\" name=\"email\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 31, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 29, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"input\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">First Name</label> <input type=\"text\" name=\"first_name\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 38, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" placeholder=\"Optional\" class=\"input\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Last Name</label> <input type=\"text\" name=\"last_name\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 50, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 48, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"Optional\" class=\"input\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Theme</label> <select name=\"theme\" class=\"input\"><option value=\"tokyo-night\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Theme == "tokyo-night" { if user.Theme == "tokyo-night" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">Tokyo Night</option> <option value=\"dracula\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ">Tokyo Night</option> <option value=\"dracula\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Theme == "dracula" { if user.Theme == "dracula" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Dracula</option> <option value=\"nord\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">Dracula</option> <option value=\"nord\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Theme == "nord" { if user.Theme == "nord" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Nord</option> <option value=\"solarized-dark\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ">Nord</option> <option value=\"solarized-dark\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Theme == "solarized-dark" { if user.Theme == "solarized-dark" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Solarized Dark</option> <option value=\"monokai\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Solarized Dark</option> <option value=\"monokai\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Theme == "monokai" { if user.Theme == "monokai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Monokai</option> <option value=\"one-dark-pro\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">Monokai</option> <option value=\"one-dark-pro\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Theme == "one-dark-pro" { if user.Theme == "one-dark-pro" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">One Dark Pro</option> <option value=\"material-dark\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">One Dark Pro</option> <option value=\"material-dark\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Theme == "material-dark" { if user.Theme == "material-dark" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">Material Dark</option></select></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "UTC" { if user.Timezone == "UTC" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Pacific/Honolulu" { if user.Timezone == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Anchorage" { if user.Timezone == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Los_Angeles" { if user.Timezone == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Denver" { if user.Timezone == "America/Denver" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Phoenix" { if user.Timezone == "America/Phoenix" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Chicago" { if user.Timezone == "America/Chicago" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/New_York" { if user.Timezone == "America/New_York" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Sao_Paulo" { if user.Timezone == "America/Sao_Paulo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Europe/London" { if user.Timezone == "Europe/London" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Europe/Paris" { if user.Timezone == "Europe/Paris" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Europe/Helsinki" { if user.Timezone == "Europe/Helsinki" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Europe/Moscow" { if user.Timezone == "Europe/Moscow" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Tehran" { if user.Timezone == "Asia/Tehran" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Dubai" { if user.Timezone == "Asia/Dubai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Karachi" { if user.Timezone == "Asia/Karachi" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Kolkata" { if user.Timezone == "Asia/Kolkata" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Dhaka" { if user.Timezone == "Asia/Dhaka" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Bangkok" { if user.Timezone == "Asia/Bangkok" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Shanghai" { if user.Timezone == "Asia/Shanghai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Tokyo" { if user.Timezone == "Asia/Tokyo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Australia/Darwin" { if user.Timezone == "Australia/Darwin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Australia/Sydney" { if user.Timezone == "Australia/Sydney" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Pacific/Auckland" { if user.Timezone == "Pacific/Auckland" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">New Zealand (UTC+12/+13)</option></select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, ">New Zealand (UTC+12/+13)</option></select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if showRoleField { if showRoleField {
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\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "user" { if user.Role == "user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">User</option> <option value=\"admin\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" { if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">Admin</option></select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Admin</option></select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div><div><div class=\"flex items-center gap-2 mb-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div></div><!-- Change Password Section --><div><h3 class=\"text-lg font-semibold mb-4 text-content\">Change Password</h3>")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if requireCurrentPassword { if requireCurrentPassword {
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\">") 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=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\" required></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"input\" minlength=\"6\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">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\">Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
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=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<!-- Admin edit: no current password required --> <p class=\"text-sm italic mb-4 text-content-muted\">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=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"input\" minlength=\"6\"></div><div><label class=\"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-content-muted\">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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 189, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 186, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">") 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=\"btn btn-primary\">Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
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);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</div><!-- Actions --><div class=\"flex justify-end gap-3 pt-6 border-t border-line\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if showCancelButton { if showCancelButton {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<button type=\"submit\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+9 -12
View File
@@ -1,28 +1,25 @@
package templates package templates
templ ProfileModal(user User) { templ ProfileModal(user User) {
<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 id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" 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="card max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-start gap-4 p-6 border-b" style="border-color: var(--border);"> <!-- Modal Header -->
<div class="flex items-center gap-3"> <div class="flex justify-between items-center p-6 border-b border-line">
<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> <div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit User Profile</h2> <h2 class="text-2xl font-bold tracking-tight text-content">Edit User Profile</h2>
<p class="text-sm mt-0.5" style="color: var(--text-secondary);"> <p class="text-sm mt-1 text-content-muted">
{ user.Username } ({ user.Email }) { user.Username } ({ user.Email })
</p> </p>
</div> </div>
</div>
<button <button
@click="closeProfileModal()" @click="closeProfileModal()"
class="icon-btn shrink-0" class="btn btn-ghost"
aria-label="Close"
> >
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<!-- Modal Body - uses reusable form -->
<div class="p-6"> <div class="p-6">
@ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true) @ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true)
</div> </div>
+7 -15
View File
@@ -29,41 +29,33 @@ func ProfileModal(user User) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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);\">") 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: var(--surface-overlay);\"><div class=\"card max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto\"><!-- Modal Header --><div class=\"flex justify-between items-center p-6 border-b border-line\"><div><h2 class=\"text-2xl font-bold tracking-tight text-content\">Edit User Profile</h2><p class=\"text-sm mt-1 text-content-muted\">")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 14, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 11, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 14, Col: 38} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 11, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ")</p></div></div><button @click=\"closeProfileModal()\" class=\"icon-btn shrink-0\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ")</p></div><button @click=\"closeProfileModal()\" class=\"btn btn-ghost\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -71,7 +63,7 @@ func ProfileModal(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</button></div><div class=\"p-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</button></div><!-- Modal Body - uses reusable form --><div class=\"p-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -79,7 +71,7 @@ func ProfileModal(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

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