Compare commits
38
Commits
v0.5.2-tighten
...
new-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d65e03555 | ||
|
|
ab465d8e0e | ||
|
|
1461273162 | ||
|
|
598d70f735 | ||
|
|
537330e7e0 | ||
|
|
885f6d8187 | ||
|
|
936a48405b | ||
|
|
757398bf15 | ||
|
|
d12911d3c8 | ||
|
|
457a38306d | ||
|
|
bc47450653 | ||
|
|
64ad10b3f2 | ||
|
|
a42232e67a | ||
|
|
006cc0c2c9 | ||
|
|
706be09dec | ||
|
|
a87c8afc22 | ||
|
|
acc9d08c60 | ||
|
|
e09c55cecc | ||
|
|
6f1c13998c | ||
|
|
0a0caa9533 | ||
|
|
4716790564 | ||
|
|
8e2c1a4b3a | ||
|
|
4a870a7f18 | ||
|
|
40dabfd788 | ||
|
|
ef3c05714a | ||
|
|
816ee0ec80 | ||
|
|
73a2852ee3 | ||
|
|
f67232a20b | ||
|
|
7b465ac97e | ||
|
|
033a012069 | ||
|
|
9165c4eb3f | ||
|
|
9ef6c5b6ed | ||
|
|
987ece38f0 | ||
|
|
b23f6b0bab | ||
|
|
8ea70ea7f8 | ||
|
|
07578e0206 | ||
|
|
f402a3ee03 | ||
|
|
f3f908eb7e |
+1
-1
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
||||
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
info:
|
||||
name: SetBaseUrl
|
||||
type: http
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
url: '{{base_url}}/api/system/config'
|
||||
auth: inherit
|
||||
body:
|
||||
type: json
|
||||
jsonBody: |-
|
||||
{
|
||||
"base_url": "http://localhost:8765"
|
||||
}
|
||||
headers:
|
||||
- key: Authorization
|
||||
value: Bearer {{token}}
|
||||
- key: Content-Type
|
||||
value: application/json
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
||||
docs: |-
|
||||
## Set Base URL
|
||||
|
||||
Configures the server's base_url during initial dev database setup.
|
||||
|
||||
Must be run after RegisterUser (which provides the auth token) and before
|
||||
any library/device creation (which require setup to be complete).
|
||||
|
||||
**Method:** PUT
|
||||
**Endpoint:** /api/system/config
|
||||
**Auth:** Bearer token (from RegisterUser)
|
||||
+57
-6
@@ -14,6 +14,8 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -48,15 +50,59 @@ func main() {
|
||||
}
|
||||
log.Println("✅ Database schema initialized and verified, starting server...")
|
||||
|
||||
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
||||
// Load tunable settings from the DB into the registry. All values fall back
|
||||
// to compiled defaults if a row is missing, so this never blocks startup.
|
||||
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.SetSettings(registry)
|
||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||
systemSettingsHandler.SetSettings(registry)
|
||||
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
||||
sidecarHandler.SetSettings(registry)
|
||||
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||
deviceAuthMiddleware.SetSettings(registry)
|
||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||
|
||||
// Create WebSocket connection manager
|
||||
@@ -64,10 +110,11 @@ func main() {
|
||||
|
||||
progressService := sync.NewProgressService(queries, connManager)
|
||||
annotationService := sync.NewAnnotationService(queries, connManager)
|
||||
tombstonePurgerCancel := annotationService.StartTombstonePurger()
|
||||
defer tombstonePurgerCancel()
|
||||
annotationService.SetSettings(registry)
|
||||
maintenanceCancel := annotationService.StartDailyMaintenance()
|
||||
defer maintenanceCancel()
|
||||
|
||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
queueProcessor.SetAnnotationService(annotationService)
|
||||
|
||||
@@ -78,7 +125,8 @@ func main() {
|
||||
libraryService.SyncAllowedExtensions(context.Background())
|
||||
|
||||
// Create worker for background tasks
|
||||
worker := services.NewWorker(3, connManager)
|
||||
workerCfg := registry.WorkerPoolConfig()
|
||||
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
|
||||
services.WorkerInstance = worker
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
@@ -91,7 +139,9 @@ func main() {
|
||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||
|
||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||
conversionService.SetSettings(registry)
|
||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||
opdsHandler.SetSettings(registry)
|
||||
|
||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||
dashboardService := services.NewDashboardService(queries)
|
||||
@@ -144,6 +194,7 @@ func main() {
|
||||
Echo: e,
|
||||
Queries: queries,
|
||||
Cfg: cfg,
|
||||
Settings: registry,
|
||||
DBPool: dbPool,
|
||||
AuthHandler: authHandler,
|
||||
LibraryHandler: libraryHandler,
|
||||
|
||||
+49
-11
@@ -45,11 +45,47 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Insert default system settings
|
||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
|
||||
('default_timezone', 'UTC', 'System default timezone')
|
||||
-- Extend system_settings with typed metadata so it can back the admin UI's
|
||||
-- configurable tunables. All columns are nullable for backward compatibility
|
||||
-- with the original three rows and any pre-existing data.
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT;
|
||||
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;
|
||||
|
||||
-- Create refresh_tokens table
|
||||
@@ -1149,12 +1185,14 @@ CREATE TABLE IF NOT EXISTS system_config (
|
||||
updated_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Pre-seeded values
|
||||
INSERT INTO system_config (key, value) VALUES
|
||||
('base_url', 'https://bookhoard.example.com'),
|
||||
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
||||
('api_base_url', 'https://bookhoard.example.com/api')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
||||
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
||||
UPDATE system_config SET value = ''
|
||||
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
||||
UPDATE system_config SET value = ''
|
||||
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
|
||||
UPDATE system_config SET value = ''
|
||||
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
|
||||
|
||||
-- Create opds_tokens table (device-specific OPDS access tokens)
|
||||
CREATE TABLE IF NOT EXISTS opds_tokens (
|
||||
|
||||
@@ -45,30 +45,19 @@ func (c *Config) DatabaseURL() string {
|
||||
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
|
||||
}
|
||||
|
||||
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
|
||||
func GetBaseURL(ctx context.Context, db interface{}) string {
|
||||
// Try to get from database first
|
||||
type SystemConfigQuerier interface {
|
||||
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
|
||||
}
|
||||
// SystemConfigGetter returns the value for a system config key, or an error.
|
||||
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
|
||||
|
||||
if querier, ok := db.(SystemConfigQuerier); ok {
|
||||
config, err := querier.GetSystemConfig(ctx, "base_url")
|
||||
if err == nil && config.Value != "" {
|
||||
return config.Value
|
||||
}
|
||||
// GetBaseURL returns the base URL from system configuration database, or empty
|
||||
// string if not set. The getter abstraction avoids importing the database package.
|
||||
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
|
||||
val, err := getter(ctx, "base_url")
|
||||
if err == nil && val != "" {
|
||||
return val
|
||||
}
|
||||
|
||||
// Fallback: return empty string - caller should use their own fallback
|
||||
return ""
|
||||
}
|
||||
|
||||
// SystemConfigRow represents a system configuration row
|
||||
type SystemConfigRow struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
@@ -484,11 +484,16 @@ type SystemConfig struct {
|
||||
}
|
||||
|
||||
type SystemSettings struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||
SettingValue string `db:"setting_value" json:"setting_value"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||
SettingValue string `db:"setting_value" json:"setting_value"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
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 {
|
||||
@@ -532,3 +537,4 @@ type Users struct {
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
@@ -26,7 +26,7 @@ type Querier interface {
|
||||
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
||||
// Cleanup expired OPDS tokens
|
||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||
CleanupExpiredRefreshTokens(ctx context.Context) error
|
||||
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
@@ -137,6 +137,7 @@ type Querier interface {
|
||||
// Get all system config
|
||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||
// Get collection
|
||||
@@ -278,6 +279,7 @@ type Querier interface {
|
||||
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
|
||||
// System Settings queries
|
||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
|
||||
GetSystemTimezone(ctx context.Context) (string, error)
|
||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||
// Get universal progress for a book
|
||||
@@ -428,6 +430,7 @@ type Querier interface {
|
||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
// source: queries.sql
|
||||
|
||||
package database
|
||||
@@ -186,11 +186,11 @@ func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error {
|
||||
}
|
||||
|
||||
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec
|
||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')
|
||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision))
|
||||
`
|
||||
|
||||
func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens)
|
||||
func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error {
|
||||
_, err := q.db.Exec(ctx, CleanupExpiredRefreshTokens, dollar_1)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2258,6 +2258,41 @@ func (q *Queries) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSetti
|
||||
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
|
||||
SELECT
|
||||
mh.id,
|
||||
@@ -6587,6 +6622,28 @@ func (q *Queries) GetSystemSetting(ctx context.Context, settingKey string) (stri
|
||||
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
|
||||
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'
|
||||
`
|
||||
@@ -12280,3 +12337,56 @@ func (q *Queries) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSett
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -373,9 +373,29 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1;
|
||||
-- name: UpdateSystemSetting :exec
|
||||
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
|
||||
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
|
||||
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -1007,7 +1027,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;
|
||||
|
||||
-- name: CleanupExpiredRefreshTokens :exec
|
||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
|
||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision));
|
||||
|
||||
-- ============================================
|
||||
-- FORMAT DETECTION & PROGRESS
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+32
-11
@@ -26,14 +26,34 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Session duration constants
|
||||
// Follows same pattern as refresh_token.go
|
||||
SessionDuration = 7 * 24 * time.Hour // 7 days
|
||||
// DefaultSessionDuration is the fallback session duration used when no
|
||||
// settings registry is wired (matches the historical 7-day value).
|
||||
DefaultSessionDuration = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// SessionDurationSec is the session duration in seconds for use in cookies and API responses
|
||||
// Note: This is computed from SessionDuration to avoid magic numbers
|
||||
var SessionDurationSec = int(SessionDuration.Seconds())
|
||||
// SessionDurationSec is retained for backward compatibility; new code uses the
|
||||
// registry via AuthHandler.sessionDuration().
|
||||
var SessionDurationSec = int(DefaultSessionDuration.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")
|
||||
|
||||
@@ -41,6 +61,7 @@ type AuthHandler struct {
|
||||
db *database.Queries
|
||||
jwtKey []byte
|
||||
loginAttemptTracker *middleware.LoginAttemptTracker
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
||||
@@ -265,7 +286,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
HttpOnly: true,
|
||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: SessionDurationSec,
|
||||
MaxAge: int(h.sessionDuration().Seconds()),
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
@@ -298,7 +319,7 @@ window.location.href = '/dashboard';
|
||||
Token: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: SessionDurationSec,
|
||||
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||
User: UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
@@ -411,7 +432,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
||||
HttpOnly: true,
|
||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: SessionDurationSec,
|
||||
MaxAge: int(h.sessionDuration().Seconds()),
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
@@ -450,7 +471,7 @@ window.location.href = '%s';
|
||||
Token: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: SessionDurationSec,
|
||||
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||
User: UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
@@ -1014,7 +1035,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
|
||||
"user_role": userRole,
|
||||
"user_email": userEmail,
|
||||
"user_username": userUsername,
|
||||
"exp": time.Now().Add(SessionDuration).Unix(),
|
||||
"exp": time.Now().Add(h.sessionDuration()).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
@@ -73,6 +73,7 @@ type BookInfo struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
HasConflict bool `json:"has_conflict"`
|
||||
}
|
||||
|
||||
type SectionData struct {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -64,6 +65,7 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
||||
}
|
||||
|
||||
sectionData := BuildSections(sections, libraryID)
|
||||
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||
}
|
||||
@@ -188,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
|
||||
return result
|
||||
}
|
||||
|
||||
// activeConflictSet returns the set of media item IDs (as strings) that have an
|
||||
// active (unresolved) progress sync conflict for the given user. A single query
|
||||
// is issued; resolved conflicts are filtered out in memory.
|
||||
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
|
||||
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]bool, len(conflicts))
|
||||
for _, c := range conflicts {
|
||||
if c.ResolutionStatus.String == "unresolved" {
|
||||
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
|
||||
// active progress sync conflict for the user. It performs a single query
|
||||
// regardless of how many books are passed.
|
||||
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
|
||||
if len(books) == 0 {
|
||||
return books
|
||||
}
|
||||
set := activeConflictSet(ctx, db, userID)
|
||||
for i := range books {
|
||||
if set[books[i].MediaItemID] {
|
||||
books[i].HasConflict = true
|
||||
}
|
||||
}
|
||||
return books
|
||||
}
|
||||
|
||||
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
|
||||
// used by the dashboard which renders books grouped into sections.
|
||||
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
|
||||
if len(sections) == 0 {
|
||||
return sections
|
||||
}
|
||||
set := activeConflictSet(ctx, db, userID)
|
||||
if len(set) == 0 {
|
||||
return sections
|
||||
}
|
||||
for s := range sections {
|
||||
for i := range sections[s].Items {
|
||||
if set[sections[s].Items[i].MediaItemID] {
|
||||
sections[s].Items[i].HasConflict = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
func getViewAllURL(collectionID string, libraryID string) string {
|
||||
if collectionID != "" {
|
||||
if libraryID != "" {
|
||||
|
||||
@@ -623,7 +623,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
|
||||
for mediaItemID, contentId := range processedBooks {
|
||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||
MediaItemID: mediaItemID,
|
||||
|
||||
@@ -854,7 +854,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
||||
}
|
||||
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
|
||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
UserID: pgUserID,
|
||||
|
||||
@@ -26,6 +26,26 @@ type OPDSHandler struct {
|
||||
conversionService interface {
|
||||
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 {
|
||||
@@ -38,15 +58,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get base URL from system config
|
||||
// Helper function to get base URL from system config with request-derived fallback
|
||||
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
||||
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
|
||||
var dbBaseURL string
|
||||
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
|
||||
dbBaseURL = config.Value
|
||||
}
|
||||
|
||||
opdsBaseURL := baseURL.Value + "/opds"
|
||||
return baseURL.Value, opdsBaseURL, nil
|
||||
baseURL := deriveBaseURL(c, dbBaseURL)
|
||||
opdsBaseURL := baseURL + "/opds"
|
||||
return baseURL, opdsBaseURL, nil
|
||||
}
|
||||
|
||||
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
||||
@@ -168,9 +188,10 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
perPageNum := 50
|
||||
perPageNum := h.opdsDefaultPageSize()
|
||||
maxPerPage := h.opdsMaxPageSize()
|
||||
if perPage != "" {
|
||||
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 {
|
||||
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
|
||||
perPageNum = num
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -139,3 +140,8 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -374,6 +374,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
deviceName = progress.LastSyncDevice.String
|
||||
}
|
||||
|
||||
lastUpdated := ""
|
||||
if progress.LastReadAt.Valid {
|
||||
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||
}
|
||||
|
||||
progressList = append(progressList, ProgressWithMedia{
|
||||
MediaItemID: progress.MediaItemID.Bytes,
|
||||
Title: mediaItem.Title,
|
||||
@@ -386,6 +391,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
Epubcfi: epubcfi,
|
||||
LastSyncDevice: deviceName,
|
||||
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||
EpubCFI: epubcfi,
|
||||
LastUpdated: lastUpdated,
|
||||
DeviceIcon: getDeviceIcon(deviceName),
|
||||
DeviceName: deviceName,
|
||||
DeviceType: deviceName,
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
})
|
||||
|
||||
@@ -14,10 +14,6 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
refreshTokenExpiration = 7 * 24 * time.Hour // 7 days
|
||||
)
|
||||
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||
}
|
||||
@@ -72,7 +68,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, RefreshTokenResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: SessionDurationSec,
|
||||
ExpiresIn: int(h.refreshTokenTTL().Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,7 +97,7 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
|
||||
tokenUUID := uuid.New()
|
||||
refreshToken := tokenUUID.String()
|
||||
|
||||
expiresAt := time.Now().Add(refreshTokenExpiration)
|
||||
expiresAt := time.Now().Add(h.refreshTokenTTL())
|
||||
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/setupstatus"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -14,14 +15,19 @@ import (
|
||||
)
|
||||
|
||||
type SidecarHandler struct {
|
||||
db *database.Queries
|
||||
cfg *config.Config
|
||||
db *database.Queries
|
||||
cfg *config.Config
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
||||
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 {
|
||||
Version string `json:"version"`
|
||||
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
||||
@@ -81,15 +87,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
userID := device.UserID.Bytes
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
// Get base URL and compute paths
|
||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
if baseURL.Value == "" {
|
||||
baseURL.Value = h.cfg.BaseURL
|
||||
}
|
||||
// Get base URL and compute paths (with request-derived fallback)
|
||||
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||
|
||||
// Generate URLs
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||
|
||||
// Get user's visible libraries with media items
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||
@@ -176,8 +180,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
Bookhoard: SidecarBookhoardConfig{
|
||||
OPDSCatalog: opdsCatalogURL,
|
||||
SyncAPI: syncAPIURL,
|
||||
OPDSBaseURL: baseURL.Value + "/opds",
|
||||
APIBaseURL: baseURL.Value + "/api",
|
||||
OPDSBaseURL: baseURL + "/opds",
|
||||
APIBaseURL: baseURL + "/api",
|
||||
DeviceID: deviceID.String(),
|
||||
DeviceToken: device.AuthToken,
|
||||
},
|
||||
@@ -219,15 +223,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
||||
userID := device.UserID.Bytes
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
// Get base URL and compute paths
|
||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
if baseURL.Value == "" {
|
||||
baseURL.Value = h.cfg.BaseURL
|
||||
}
|
||||
// Get base URL and compute paths (with request-derived fallback)
|
||||
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||
|
||||
// Generate URLs
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||
|
||||
// Get user's visible libraries with media items
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||
@@ -309,8 +311,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
||||
Bookhoard: SidecarBookhoardConfig{
|
||||
OPDSCatalog: opdsCatalogURL,
|
||||
SyncAPI: syncAPIURL,
|
||||
OPDSBaseURL: baseURL.Value + "/opds",
|
||||
APIBaseURL: baseURL.Value + "/api",
|
||||
OPDSBaseURL: baseURL + "/opds",
|
||||
APIBaseURL: baseURL + "/api",
|
||||
DeviceID: deviceID.String(),
|
||||
DeviceToken: device.AuthToken,
|
||||
},
|
||||
@@ -403,6 +405,9 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
"error": "failed to update default timezone",
|
||||
})
|
||||
}
|
||||
if h.settings != nil {
|
||||
h.settings.Reload(ctx)
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||
@@ -434,6 +439,10 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate setup status cache so the middleware picks up the new
|
||||
// base_url immediately (setup is not complete until base_url is set).
|
||||
setupstatus.Invalidate()
|
||||
}
|
||||
|
||||
// Check for HTMX request
|
||||
|
||||
@@ -2,17 +2,21 @@ package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type SystemSettingsHandler struct {
|
||||
db *database.Queries
|
||||
db *database.Queries
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
||||
@@ -21,6 +25,154 @@ 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 {
|
||||
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
||||
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
||||
@@ -51,6 +203,7 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.reload(c)
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||
}
|
||||
|
||||
@@ -88,6 +241,8 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
h.reload(c)
|
||||
|
||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
||||
AutoScanEnabled: req.AutoScanEnabled,
|
||||
@@ -96,6 +251,15 @@ func (h *SystemSettingsHandler) UpdateScanSettings(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")
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// deriveBaseURL returns the base URL to use for constructing self-referential
|
||||
// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured
|
||||
// base_url when available, and falls back to deriving the URL from the incoming
|
||||
// HTTP request (Host header + scheme), which is always reachable by the client.
|
||||
//
|
||||
// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so
|
||||
// that deployments behind TLS-terminating reverse proxies advertise the correct
|
||||
// external URL.
|
||||
func deriveBaseURL(c *echo.Context, dbBaseURL string) string {
|
||||
if dbBaseURL != "" {
|
||||
return strings.TrimRight(dbBaseURL, "/")
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if c.Request().TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
|
||||
host := c.Request().Host
|
||||
if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" {
|
||||
host = forwarded
|
||||
}
|
||||
|
||||
return scheme + "://" + host
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type DeviceContext struct {
|
||||
type DeviceAuthMiddleware struct {
|
||||
db *database.Queries
|
||||
rateLimiter *DeviceRateLimiter
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
||||
@@ -34,6 +35,41 @@ 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 {
|
||||
return func(c *echo.Context) error {
|
||||
var device database.Devices
|
||||
@@ -115,15 +151,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
||||
deviceUUID := uuid.UUID(device.ID.Bytes)
|
||||
deviceID := deviceUUID.String()
|
||||
|
||||
config := DeviceRateLimitConfig{
|
||||
SyncRequestsPerMinute: 60,
|
||||
ProgressUpdatesPerMinute: 120,
|
||||
MetadataRequestsPerMinute: 30,
|
||||
}
|
||||
config := m.rateLimitConfig()
|
||||
limitForType := m.rateLimitForRequestType(requestType, config)
|
||||
|
||||
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
||||
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
|
||||
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
||||
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||
@@ -134,7 +167,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
||||
}
|
||||
|
||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
||||
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
|
||||
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||
|
||||
ctx := DeviceContext{
|
||||
|
||||
@@ -1,96 +1,146 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// PasswordValidator validates password complexity requirements
|
||||
type PasswordValidator struct{}
|
||||
// specialCharRegex matches the historical "special character" set used by the
|
||||
// password complexity rules.
|
||||
const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`
|
||||
|
||||
// Validate checks if a password meets complexity requirements:
|
||||
// - Minimum 8 characters
|
||||
// - At least one uppercase letter
|
||||
// - At least one lowercase letter
|
||||
// - At least one number
|
||||
// - At least one special character
|
||||
// PasswordValidator validates password complexity against the configured rules.
|
||||
// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the
|
||||
// regex set is recompiled under a mutex on each validation. Without a registry
|
||||
// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply.
|
||||
type PasswordValidator struct {
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
// 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 {
|
||||
password := fl.Field().String()
|
||||
return v.CheckPassword(fl.Field().String())
|
||||
}
|
||||
|
||||
// Check minimum length
|
||||
if len(password) < 8 {
|
||||
// CheckPassword applies the active rules to a single password.
|
||||
func (v *PasswordValidator) CheckPassword(password string) bool {
|
||||
r := v.rules()
|
||||
if len(password) < r.MinLength {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for uppercase
|
||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||
if !hasUpper {
|
||||
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for lowercase
|
||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
||||
if !hasLower {
|
||||
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for number
|
||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
||||
if !hasNumber {
|
||||
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for special character
|
||||
hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
|
||||
if !hasSpecial {
|
||||
if r.Special && !specialRegex().MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetPasswordRequirements returns a human-readable list of password requirements
|
||||
// GetPasswordRequirements returns a human-readable list of the active password
|
||||
// requirements, driven by the configured rules when a registry is wired.
|
||||
func GetPasswordRequirements() []string {
|
||||
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 (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
|
||||
}
|
||||
return defaultPasswordValidator.Requirements()
|
||||
}
|
||||
|
||||
// ValidatePassword checks a password and returns an error if it doesn't meet requirements
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters long")
|
||||
// Requirements returns the human-readable list for the receiver's active rules.
|
||||
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
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||
// 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 {
|
||||
v := defaultPasswordValidator
|
||||
r := v.rules()
|
||||
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) {
|
||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one number")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
|
||||
if r.Special && !specialRegex().MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one special character")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterPasswordValidation registers the password validator with the validator instance
|
||||
// defaultPasswordValidator is used by the package-level helpers
|
||||
// (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 {
|
||||
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
||||
pv := &PasswordValidator{}
|
||||
return pv.Validate(fl)
|
||||
return defaultPasswordValidator.CheckPassword(fl.Field().String())
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
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))
|
||||
})
|
||||
}
|
||||
+73
-18
@@ -9,7 +9,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
@@ -215,6 +214,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
bookInfoList = []handlers.BookInfo{}
|
||||
}
|
||||
|
||||
seriesUserUUID, _ := uuid.Parse(user.ID)
|
||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
@@ -267,6 +269,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
bookInfoList = []handlers.BookInfo{}
|
||||
}
|
||||
|
||||
tagUserUUID, _ := uuid.Parse(user.ID)
|
||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: tagUserUUID, Valid: true}, bookInfoList)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
@@ -354,6 +359,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
||||
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -408,7 +414,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Get only visible sections for the dashboard display
|
||||
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
||||
|
||||
sectionData := handlers.BuildSections(visibleSections, libraryID)
|
||||
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -623,6 +630,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
Description: collection.Description.String,
|
||||
Color: collection.Color.String,
|
||||
Icon: collection.Icon.String,
|
||||
IsSystem: collection.IsSystemCollection.Bool,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -725,10 +733,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
// Get base URL from database config with fallback to config/env var
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||
@@ -805,8 +810,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
stats := getAdminStats(c.Request().Context(), cfg)
|
||||
var buf bytes.Buffer
|
||||
err = templates.Admin(user).Render(c.Request().Context(), &buf)
|
||||
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -818,8 +824,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
stats := getAdminStats(c.Request().Context(), cfg)
|
||||
var buf bytes.Buffer
|
||||
err = templates.Admin(user).Render(c.Request().Context(), &buf)
|
||||
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -843,11 +850,14 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
libData := make([]templates.LibraryData, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
|
||||
libData[i] = templates.LibraryData{
|
||||
ID: libUUID.String(),
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
TypeValue: lib.TypeName,
|
||||
FolderCount: len(folders),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -985,24 +995,67 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Fetch current system configuration - just base_url
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(ctx)
|
||||
|
||||
systemConfig := map[string]string{
|
||||
"base_url": baseURL,
|
||||
"default_timezone": "UTC",
|
||||
}
|
||||
|
||||
defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
|
||||
defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx)
|
||||
if err == nil && 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
|
||||
err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf)
|
||||
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1044,6 +1097,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
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)
|
||||
// ============================================================================
|
||||
@@ -1077,10 +1135,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
// Get base URL from database config with fallback to config/env var
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||
|
||||
@@ -38,6 +38,8 @@ func registerLibraryRoutes(cfg *Config) {
|
||||
adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats)
|
||||
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
||||
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 {
|
||||
libraryID := c.Param("id")
|
||||
scanReq := map[string]interface{}{
|
||||
|
||||
@@ -39,6 +39,7 @@ type Config struct {
|
||||
Echo *echo.Echo
|
||||
Queries *database.Queries
|
||||
Cfg *config.Config
|
||||
Settings *database.SettingsRegistry
|
||||
DBPool interface{} // pgxpool.Pool interface
|
||||
AuthHandler *handlers.AuthHandler
|
||||
LibraryHandler *handlers.LibraryHandler
|
||||
@@ -72,6 +73,24 @@ type Config struct {
|
||||
LibraryService *services.LibraryService
|
||||
}
|
||||
|
||||
// getBaseURL returns the configured base URL from the database, falling back to
|
||||
// the env var / config default. Uses a closure to adapt the database query to
|
||||
// config.SystemConfigGetter.
|
||||
func (cfg *Config) getBaseURL(ctx context.Context) string {
|
||||
getter := func(ctx context.Context, key string) (string, error) {
|
||||
row, err := cfg.Queries.GetSystemConfig(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return row.Value, nil
|
||||
}
|
||||
baseURL := config.GetBaseURL(ctx, getter)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return echojwt.WithConfig(echojwt.Config{
|
||||
@@ -193,10 +212,12 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
// Setup redirect middleware - must run before all routes
|
||||
e.Pre(setupRedirectMiddleware(cfg))
|
||||
|
||||
// Rate limiter
|
||||
// Rate limiter. The per-minute value comes from the settings registry (DB);
|
||||
// the enabled flag stays env-driven since disabling rate limiting is a
|
||||
// deployment-time decision, not a runtime tunable.
|
||||
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
||||
Enabled: cfg.Cfg.RateLimitEnabled,
|
||||
RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
|
||||
RequestsPerMinute: cfg.Settings.AuthRateLimit(),
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
|
||||
|
||||
@@ -112,9 +112,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
||||
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
||||
}
|
||||
}
|
||||
// Render using BooksGrid template
|
||||
// Stamp active conflict flags so cards route the play action correctly
|
||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
|
||||
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
||||
var buf bytes.Buffer
|
||||
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
||||
if c.QueryParam("show_checkbox") == "true" {
|
||||
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
|
||||
} else {
|
||||
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Template render error: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
|
||||
|
||||
+40
-10
@@ -14,7 +14,40 @@ import (
|
||||
)
|
||||
|
||||
func isSetupComplete(cfg *Config) bool {
|
||||
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
|
||||
getter := func(ctx context.Context) (string, error) {
|
||||
row, err := cfg.Queries.GetSystemConfig(ctx, "base_url")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return row.Value, nil
|
||||
}
|
||||
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries, getter)
|
||||
}
|
||||
|
||||
// setupAllowedAPIRoutes lists API endpoints that remain accessible before
|
||||
// initial setup is complete so the server can be configured via API.
|
||||
var setupAllowedAPIRoutes = []string{
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
"/api/system/config",
|
||||
}
|
||||
|
||||
// isAllowedDuringSetup reports whether a request path should bypass the setup
|
||||
// gate. This includes the setup page itself, static assets, health checks, and
|
||||
// the minimal set of API routes needed to perform initial configuration.
|
||||
func isAllowedDuringSetup(path string) bool {
|
||||
if path == "/setup" || path == "/setup/" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||
return true
|
||||
}
|
||||
for _, route := range setupAllowedAPIRoutes {
|
||||
if path == route || strings.HasPrefix(path, route+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
@@ -22,19 +55,16 @@ func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return func(c *echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
|
||||
if path == "/setup" || path == "/setup/" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||
if isAllowedDuringSetup(path) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if !isSetupComplete(cfg) {
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Server setup is not complete. Configure an admin account and base_url via the setup wizard or API.",
|
||||
})
|
||||
}
|
||||
return c.Redirect(http.StatusFound, "/setup")
|
||||
}
|
||||
|
||||
|
||||
@@ -17,4 +17,10 @@ func registerSystemRoutes(cfg *Config) {
|
||||
// System configuration routes (admin-only)
|
||||
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ import (
|
||||
"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 {
|
||||
Path string
|
||||
SHA256 string
|
||||
@@ -27,6 +31,7 @@ type ConversionService struct {
|
||||
cacheDir string
|
||||
conversionTool string
|
||||
conversionCacheTTL time.Duration
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
|
||||
@@ -34,17 +39,32 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ
|
||||
db: db,
|
||||
cacheDir: cacheDir,
|
||||
conversionTool: "/usr/bin/kepubify",
|
||||
conversionCacheTTL: 24 * time.Hour,
|
||||
conversionCacheTTL: defaultConversionCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
||||
MediaItemID: mediaItemID,
|
||||
FormatType: "kepub",
|
||||
})
|
||||
if err == nil && existing.FilePath.Valid {
|
||||
if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL {
|
||||
if time.Since(existing.CreatedAt.Time) < s.cacheTTL() {
|
||||
return &ConvertedKEPUB{
|
||||
Path: existing.FilePath.String,
|
||||
SHA256: existing.FileSha256.String,
|
||||
|
||||
@@ -65,5 +65,7 @@ func TestConversionServiceDefaults(t *testing.T) {
|
||||
assert.NotNil(t, service)
|
||||
assert.Equal(t, cacheDir, service.cacheDir)
|
||||
assert.Equal(t, "/usr/bin/kepubify", service.conversionTool)
|
||||
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours")
|
||||
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL field 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")
|
||||
}
|
||||
|
||||
@@ -176,10 +176,17 @@ func (w *Worker) GetActiveJobCount() int {
|
||||
return count
|
||||
}
|
||||
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())
|
||||
|
||||
w := &Worker{
|
||||
jobQueue: make(chan *Job, 100),
|
||||
jobQueue: make(chan *Job, queueCap),
|
||||
results: make(map[string]*JobResult),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@@ -995,3 +1002,4 @@ func (w *Worker) Shutdown() {
|
||||
close(w.jobQueue)
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Package setupstatus reports whether the application's initial setup has been
|
||||
// completed. Setup is considered complete as soon as at least one admin user
|
||||
// exists, regardless of how that user was created (setup wizard, API, or a
|
||||
// future CLI). This keeps the setup gate a derived property of real data
|
||||
// rather than a manually-flipped flag that can drift out of sync.
|
||||
// completed. Setup is considered complete when at least one admin user exists
|
||||
// AND a non-empty base_url has been configured, regardless of how those were
|
||||
// created (setup wizard, API, or a future CLI). This keeps the setup gate a
|
||||
// derived property of real data rather than a manually-flipped flag that can
|
||||
// drift out of sync.
|
||||
package setupstatus
|
||||
|
||||
import (
|
||||
@@ -18,6 +19,12 @@ type AdminCounter interface {
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
// BaseURLGetter returns the configured base_url value from the database, or an
|
||||
// error if it cannot be read. Defined as a function type (not an interface) so
|
||||
// it can be satisfied by a closure wrapping *database.Queries.GetSystemConfig
|
||||
// without importing the database package.
|
||||
type BaseURLGetter func(ctx context.Context) (string, error)
|
||||
|
||||
var (
|
||||
cacheMu sync.RWMutex
|
||||
cacheComplete bool = true
|
||||
@@ -26,10 +33,11 @@ var (
|
||||
)
|
||||
|
||||
// IsSetupComplete reports whether setup is complete. Setup is complete when at
|
||||
// least one admin user exists. A short in-memory cache avoids hammering the
|
||||
// database on every request. On a database error the function fails open
|
||||
// (returns true) so a transient outage does not lock users out of the app.
|
||||
func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
||||
// least one admin user exists AND base_url is configured. A short in-memory
|
||||
// cache avoids hammering the database on every request. On a database error the
|
||||
// function fails open (returns true) so a transient outage does not lock users
|
||||
// out of the app.
|
||||
func IsSetupComplete(ctx context.Context, q AdminCounter, baseURLGetter BaseURLGetter) bool {
|
||||
cacheMu.RLock()
|
||||
if time.Now().Before(cacheExpiry) {
|
||||
complete := cacheComplete
|
||||
@@ -38,12 +46,20 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
||||
}
|
||||
cacheMu.RUnlock()
|
||||
|
||||
count, err := q.CountAdmins(ctx)
|
||||
complete := true
|
||||
|
||||
count, err := q.CountAdmins(ctx)
|
||||
if err == nil {
|
||||
complete = count > 0
|
||||
}
|
||||
|
||||
if complete && baseURLGetter != nil {
|
||||
baseURL, err := baseURLGetter(ctx)
|
||||
if err == nil {
|
||||
complete = baseURL != ""
|
||||
}
|
||||
}
|
||||
|
||||
cacheMu.Lock()
|
||||
cacheComplete = complete
|
||||
cacheExpiry = time.Now().Add(cacheTTL)
|
||||
@@ -53,7 +69,8 @@ func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
||||
|
||||
// Invalidate clears the cached setup status so the next call to IsSetupComplete
|
||||
// re-reads from the database. Call this after any write that could change the
|
||||
// admin user count (user creation, role promotion/demotion, user deletion).
|
||||
// admin user count (user creation, role promotion/demotion, user deletion) or
|
||||
// the base_url configuration.
|
||||
func Invalidate() {
|
||||
cacheMu.Lock()
|
||||
cacheComplete = true
|
||||
|
||||
@@ -18,8 +18,38 @@ import (
|
||||
"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
|
||||
|
||||
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
|
||||
|
||||
const (
|
||||
@@ -29,15 +59,6 @@ const (
|
||||
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 {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
@@ -79,7 +100,7 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight
|
||||
}
|
||||
|
||||
if existing.Deleted.Bool {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
|
||||
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
return s.createHighlight(ctx, req, dedupKey)
|
||||
@@ -238,7 +259,7 @@ func (s *AnnotationService) TombstoneHighlightByID(
|
||||
}
|
||||
|
||||
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true}
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true}
|
||||
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("purge highlight tombstones: %w", err)
|
||||
}
|
||||
@@ -251,7 +272,13 @@ func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
|
||||
// StartDailyMaintenance launches a single background goroutine that runs all
|
||||
// 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)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -262,9 +289,7 @@ func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.PurgeExpiredTombstones(ctx); err != nil {
|
||||
log.Printf("AnnotationService: tombstone purge failed: %v", err)
|
||||
}
|
||||
s.runDailyMaintenance(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -272,6 +297,28 @@ func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
|
||||
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 {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
@@ -457,7 +504,7 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
|
||||
}
|
||||
|
||||
if existing.Deleted.Bool {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() {
|
||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
return s.createBookmark(ctx, req, dedupKey)
|
||||
|
||||
@@ -74,11 +74,18 @@ type SyncQueueItem struct {
|
||||
}
|
||||
|
||||
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{
|
||||
db: db,
|
||||
progressChan: make(chan *ProgressUpdate, 100),
|
||||
interval: 5 * time.Second,
|
||||
batchSize: 50,
|
||||
interval: interval,
|
||||
batchSize: batchSize,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-18
@@ -44,25 +44,43 @@ const config: Config = {
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: "#7aa2f7",
|
||||
50: "#f0f9ff",
|
||||
100: "#e0f2fe",
|
||||
200: "#bae6fd",
|
||||
300: "#7dd3fc",
|
||||
400: "#38bdf8",
|
||||
500: "#0ea5e9",
|
||||
600: "#0284c7",
|
||||
700: "#0369a1",
|
||||
800: "#075985",
|
||||
900: "#0c4a6e",
|
||||
// Semantic surface tokens (page bg, cards, raised layers)
|
||||
surface: {
|
||||
DEFAULT: "var(--bg-primary)",
|
||||
raised: "var(--bg-secondary)",
|
||||
hover: "var(--surface-hover)",
|
||||
overlay: "var(--surface-overlay)",
|
||||
},
|
||||
"bg-primary": "var(--bg-primary)",
|
||||
"bg-secondary": "var(--bg-secondary)",
|
||||
"text-primary": "var(--text-primary)",
|
||||
"text-secondary": "var(--text-secondary)",
|
||||
accent: "var(--accent)",
|
||||
border: "var(--border)",
|
||||
// Text tokens
|
||||
content: {
|
||||
DEFAULT: "var(--text-primary)",
|
||||
muted: "var(--text-secondary)",
|
||||
},
|
||||
// Accent / brand
|
||||
brand: {
|
||||
DEFAULT: "var(--accent)",
|
||||
muted: "var(--accent-muted)",
|
||||
},
|
||||
// Borders / hairlines
|
||||
line: {
|
||||
DEFAULT: "var(--border)",
|
||||
strong: "var(--border-strong)",
|
||||
},
|
||||
// Status colors (theme-aware via vars, fall back to fixed)
|
||||
success: "var(--status-success)",
|
||||
warning: "var(--status-warning)",
|
||||
danger: "var(--status-danger)",
|
||||
info: "var(--status-info)",
|
||||
},
|
||||
borderRadius: {
|
||||
xl: "0.875rem",
|
||||
"2xl": "1.25rem",
|
||||
},
|
||||
boxShadow: {
|
||||
card: "var(--shadow-card)",
|
||||
"card-hover": "var(--shadow-card-hover)",
|
||||
pop: "var(--shadow-pop)",
|
||||
bar: "var(--shadow-bar)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"wood-light": "url('/static/textures/wood-light.png')",
|
||||
|
||||
+112
-94
@@ -1,118 +1,136 @@
|
||||
package templates
|
||||
|
||||
templ Admin(user User) {
|
||||
templ Admin(user User, stats AdminStats) {
|
||||
<!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"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="admin" x-init="loadWatchStatus(); initializeScanWebSocket()" class="theme-tokyo-night">
|
||||
<body x-data="admin" x-init="loadWatchStatus()" class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin")
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
@AdminSidebar(user, "/admin")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Dashboard</h1>
|
||||
<p style="color: var(--text-secondary)">Overview of your Bookhoard library and settings</p>
|
||||
<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);">
|
||||
@Icon("grid", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Dashboard</h1>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-3xl">📖</div>
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Library</h3>
|
||||
<p style="color: var(--text-secondary)" class="text-sm">Manage your ebook collection</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/" class="mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded">View Library</a>
|
||||
</div>
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="text-3xl">👁️</div>
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Watch Status</h3>
|
||||
<p style="color: var(--text-secondary)" class="text-sm">Auto-detecting new files</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="watch-status" class="mt-4 text-sm" style="color: var(--text-secondary)">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
|
||||
Watching <span id="watch-count">0</span> libraries
|
||||
</div>
|
||||
<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);">
|
||||
@Icon("library", "h-4 w-4")
|
||||
<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="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Quick Actions</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button @click="scanAllLibraries()" class="btn-primary p-4 rounded-lg text-left">
|
||||
<div class="font-medium">Rescan Library</div>
|
||||
<div style="color: var(--text-secondary)" class="text-sm">Re-scan existing files and fix metadata</div>
|
||||
</button>
|
||||
<a href="/admin/library" class="btn-secondary p-4 rounded-lg text-left block">
|
||||
<div class="font-medium">Manage Libraries and Folders</div>
|
||||
<div style="color: var(--text-secondary)" class="text-sm">Add or remove libraries and scan directories</div>
|
||||
</a>
|
||||
<div 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>
|
||||
<!-- Scan Progress Section -->
|
||||
<div id="scan-progress-container" class="hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">
|
||||
📚 Scanning Libraries
|
||||
</h3>
|
||||
<button @click="hideScanProgress()" class="p-2 hover:bg-gray-700 rounded">
|
||||
✕
|
||||
</button>
|
||||
<div 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>
|
||||
<!-- Overall Progress -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span style="color: var(--text-secondary)">Overall Progress</span>
|
||||
<span id="scan-progress-text" style="color: var(--text-primary)">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-700 rounded-full h-3">
|
||||
<div
|
||||
id="scan-progress-bar"
|
||||
class="h-3 rounded-full transition-all duration-500"
|
||||
style="width: 0%; background-color: var(--accent);"
|
||||
></div>
|
||||
</div>
|
||||
<div id="scan-status" class="text-sm mt-2" style="color: var(--text-secondary)">
|
||||
Starting scan...
|
||||
</div>
|
||||
<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>
|
||||
<!-- Per-Library Progress -->
|
||||
<div id="library-progress-list" class="space-y-3">
|
||||
<!-- Dynamically populated -->
|
||||
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ stats.DeviceCount }</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);">
|
||||
@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>
|
||||
<!-- Results Summary -->
|
||||
<div id="scan-results" class="hidden mt-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<h4 class="font-semibold mb-2" style="color: var(--text-primary)">✅ Scan Complete!</h4>
|
||||
<div id="scan-results-content" style="color: var(--text-secondary)">
|
||||
<!-- Results populated by JS -->
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
@click="window.location.reload()"
|
||||
class="btn-primary px-4 py-2 rounded-lg"
|
||||
>
|
||||
Refresh to View Books
|
||||
</button>
|
||||
<button
|
||||
@click="hideScanProgress()"
|
||||
class="btn-secondary px-4 py-2 rounded-lg"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
<div 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>
|
||||
</main>
|
||||
</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">
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
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">
|
||||
@Icon("library", "h-5 w-5")
|
||||
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)">
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
Scanning Libraries
|
||||
</h3>
|
||||
<button @click="hideScanProgress()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</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);">
|
||||
@Icon("check-circle", "h-5 w-5")
|
||||
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>
|
||||
}
|
||||
|
||||
+374
-110
@@ -6,150 +6,414 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Library 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 x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/library")
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
@AdminSidebar(user, "/admin/library")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="w-full">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
<button data-action="show-create-modal" class="btn-primary px-4 py-2 rounded-lg font-medium">
|
||||
+ Create Library
|
||||
</button>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Library Management</h1>
|
||||
<p style="color: var(--text-secondary)">Manage libraries and configure media scanning</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Libraries Section -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Libraries</h3>
|
||||
<p style="color: var(--text-secondary)" class="mb-4">Manage media libraries and their folders</p>
|
||||
<div id="libraries-list" class="space-y-3 mb-6">
|
||||
if len(libraries) == 0 {
|
||||
<p style="color: var(--text-secondary)" class="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 rounded-lg" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h4 class="font-semibold" style="color: var(--text-primary)">{ library.Name }</h4>
|
||||
if library.Description != "" {
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ library.Description }</p>
|
||||
}
|
||||
<span class="inline-block px-2 py-1 text-xs rounded" style="background-color: var(--accent); color: var(--bg-primary)">
|
||||
{ library.TypeName }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button data-library-id={ library.ID } data-action="show-folders" class="text-xs px-2 py-1 rounded" style="background-color: var(--bg-secondary); color: var(--text-primary)">Folders</button>
|
||||
<button data-library-id={ library.ID } data-action="edit" class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary)">Edit</button>
|
||||
<button data-library-id={ library.ID } data-action="delete" class="text-xs px-2 py-1 rounded text-red-500">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id={ "library-folders-" + library.ID } class="hidden mt-3 space-y-2"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<main class="p-8">
|
||||
<div class="max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap mb-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</span>
|
||||
<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>
|
||||
<!-- User Library Visibility Section -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Library Visibility</h3>
|
||||
<p style="color: var(--text-secondary)" class="mb-4">Control which libraries are visible to users</p>
|
||||
<div id="visibility-controls" class="space-y-4">
|
||||
<!-- Visibility controls will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User Visibility Management -->
|
||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">User Library Access</h3>
|
||||
<p style="color: var(--text-secondary)" class="mb-4">Manage individual user access to specific libraries</p>
|
||||
<div class="mb-4">
|
||||
<select id="user-select" onchange="loadUserVisibility()" class="px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="">Select a user...</option>
|
||||
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>
|
||||
<button
|
||||
onclick="document.getElementById('create-library-modal').classList.remove('hidden')"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Create Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- Create Library Modal -->
|
||||
<div id="create-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div id="libraries-container">
|
||||
@LibraryList(user, libraries, users)
|
||||
</div>
|
||||
</div>
|
||||
</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" data-action="hide-create-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<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>
|
||||
<form id="create-library-form">
|
||||
<input type="hidden" id="library-id" name="id"/>
|
||||
<form
|
||||
hx-post="/admin/library/create"
|
||||
hx-target="#libraries-container"
|
||||
hx-swap="innerHTML"
|
||||
onsubmit="document.getElementById('create-library-modal').classList.add('hidden')"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Name</label>
|
||||
<input type="text" name="name" placeholder="My Ebook Library" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Name</label>
|
||||
<input type="text" name="name" placeholder="My Ebook Library" class="input" required/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Description</label>
|
||||
<textarea name="description" placeholder="Optional description" rows="3" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"></textarea>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
|
||||
<textarea name="description" placeholder="Optional description" rows="3" class="input"></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Library Type</label>
|
||||
<select name="type" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required>
|
||||
<option value="ebooks">📚 Ebooks</option>
|
||||
<option value="comics">📖 Comics</option>
|
||||
<option value="manga">🗾 Manga</option>
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
|
||||
<select name="type" class="input" required>
|
||||
<option value="ebooks">Ebooks</option>
|
||||
<option value="comics">Comics</option>
|
||||
<option value="manga">Manga</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" data-action="hide-create-modal" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create</button>
|
||||
<button type="button" onclick="document.getElementById('create-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Folder Browser Modal -->
|
||||
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-lg mx-4" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
|
||||
<button type="button" data-action="browse-cancel" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
</div>
|
||||
<div id="folder-browser-content">
|
||||
<!-- Directory listings will be rendered here -->
|
||||
<button type="button" onclick="document.getElementById('folder-browser-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div id="folder-browser-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Library Confirmation Modal -->
|
||||
<div id="delete-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<!-- Delete Library Modal -->
|
||||
<div id="delete-library-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md mx-4" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Delete Library</h2>
|
||||
<button type="button" data-action="hide-delete-modal" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
</div>
|
||||
<div id="delete-modal-content" class="mb-6" style="color: var(--text-primary)">
|
||||
<!-- Dynamic content will be injected here -->
|
||||
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</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">
|
||||
<button type="button" data-action="hide-delete-modal" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button type="button" data-action="confirm-delete" class="btn-primary px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600">Delete</button>
|
||||
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="delete-library-confirm" class="btn btn-danger">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<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>
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Library Management - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"library\" x-init=\"initializeLibraryAdmin\" class=\"theme-{ user.Theme }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Library 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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,169 +37,893 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap mb-4\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = AdminSidebar(user, "/admin/library").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"w-full\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><a href=\"/admin\" class=\"btn-secondary px-4 py-2 rounded-lg font-medium\">← Back to Dashboard</a> <button data-action=\"show-create-modal\" class=\"btn-primary px-4 py-2 rounded-lg font-medium\">+ Create Library</button></div><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Library Management</h1><p style=\"color: var(--text-secondary)\">Manage libraries and configure media scanning</p></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-8\"><!-- Libraries Section --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Libraries</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Manage media libraries and their folders</p><div id=\"libraries-list\" class=\"space-y-3 mb-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><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\">")
|
||||
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, 4, "Create Library</button></div></div><div id=\"libraries-container\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = LibraryList(user, libraries, users).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div></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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</button></div><form hx-post=\"/admin/library/create\" hx-target=\"#libraries-container\" hx-swap=\"innerHTML\" onsubmit=\"document.getElementById('create-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\" placeholder=\"My Ebook Library\" class=\"input\" required></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" placeholder=\"Optional description\" rows=\"3\" class=\"input\"></textarea></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Library Type</label> <select name=\"type\" class=\"input\" required><option value=\"ebooks\">Ebooks</option> <option value=\"comics\">Comics</option> <option value=\"manga\">Manga</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"document.getElementById('create-library-modal').classList.add('hidden')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</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\">")
|
||||
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, 9, "Save</button></div></form></div></div><!-- Folder Browser Modal --><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-lg mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" onclick=\"document.getElementById('folder-browser-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</button></div><div id=\"folder-browser-content\"></div></div></div><!-- Delete Library Modal --><div id=\"delete-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Delete Library</h2><button type=\"button\" onclick=\"document.getElementById('delete-library-modal').classList.add('hidden')\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button></div><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\"><button type=\"button\" onclick=\"document.getElementById('delete-library-modal').classList.add('hidden')\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" id=\"delete-library-confirm\" 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, 12, "Delete</button></div></div></div><script>\n\t\t\t\tfunction toggleLibraryPanel(btn) {\n\t\t\t\t\tvar libId = btn.dataset.libId;\n\t\t\t\t\tvar panel = document.getElementById('library-panel-' + libId);\n\t\t\t\t\tif (panel.innerHTML.trim() !== '') {\n\t\t\t\t\t\tpanel.innerHTML = '';\n\t\t\t\t\t} else {\n\t\t\t\t\t\thtmx.ajax('GET', '/admin/library/' + libId + '/panel', {\n\t\t\t\t\t\t\ttarget: '#library-panel-' + libId,\n\t\t\t\t\t\t\tswap: 'innerHTML'\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfunction openEditModal(id, name, description) {\n\t\t\t\t\tdocument.getElementById('edit-library-form').setAttribute('hx-put', '/admin/library/' + id);\n\t\t\t\t\tdocument.getElementById('edit-library-name').value = name;\n\t\t\t\t\tdocument.getElementById('edit-library-desc').value = description;\n\t\t\t\t\thtmx.process(document.getElementById('edit-library-form'));\n\t\t\t\t\tdocument.getElementById('edit-library-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t\tfunction openDeleteModal(id, name) {\n\t\t\t\t\tconst btn = document.getElementById('delete-library-confirm');\n\t\t\t\t\tbtn.setAttribute('hx-delete', '/admin/library/' + id);\n\t\t\t\t\tbtn.setAttribute('hx-target', '#libraries-container');\n\t\t\t\t\tbtn.setAttribute('hx-swap', 'innerHTML');\n\t\t\t\t\tdocument.getElementById('delete-library-name').textContent = name;\n\t\t\t\t\thtmx.process(btn);\n\t\t\t\t\tdocument.getElementById('delete-library-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t\tfunction openFolderBrowser(targetInputId, libraryId) {\n\t\t\t\t\tconst content = document.getElementById('folder-browser-content');\n\t\t\t\t\tcontent.setAttribute('hx-get', '/admin/library/browse');\n\t\t\t\t\tcontent.setAttribute('hx-vals', '{\"target_input\": \"' + targetInputId + '\", \"library_id\": \"' + libraryId + '\"}');\n\t\t\t\t\tcontent.setAttribute('hx-trigger', 'load');\n\t\t\t\t\thtmx.process(content);\n\t\t\t\t\tdocument.getElementById('folder-browser-modal').classList.remove('hidden');\n\t\t\t\t}\n\t\t\t</script></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func LibraryList(user User, libraries []LibraryData, users []User) 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_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if len(libraries) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p style=\"color: var(--text-secondary)\" class=\"text-center py-8\">No libraries yet. Create your first library to get started.</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("library", "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, 14, "</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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, library := range libraries {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"p-4 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border)\"><div class=\"flex justify-between items-start mb-2\"><div><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 44, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h4>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold truncate\" 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(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 207, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if library.Description != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"text-sm truncate\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 46, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 209, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"inline-block px-2 py-1 text-xs rounded\" style=\"background-color: var(--accent); color: var(--bg-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><span class=\"chip shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 49, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></div><div class=\"flex space-x-2\"><button data-library-id=\"")
|
||||
templ_7745c5c3_Err = Icon("tag", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 53, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 214, Col: 26}
|
||||
}
|
||||
_, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-action=\"show-folders\" class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--bg-secondary); color: var(--text-primary)\">Folders</button> <button data-library-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 50}
|
||||
if library.FolderCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"chip shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-3 w-3").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 219, Col: 30}
|
||||
}
|
||||
_, 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, 24, " folders</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-action=\"edit\" class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: var(--bg-primary)\">Edit</button> <button data-library-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div class=\"flex items-center gap-2 shrink-0\"><button hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 55, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 225, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-action=\"delete\" class=\"text-xs px-2 py-1 rounded text-red-500\">Delete</button></div></div><div id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-vals='{\"force\": \"true\"}' hx-target=\"#scan-indicator\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-xs px-3 py-1.5\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "Scan</button> <button data-lib-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-folders-" + library.ID)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 58, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 235, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"hidden mt-3 space-y-2\"></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" onclick=\"toggleLibraryPanel(this)\" class=\"btn btn-secondary text-xs px-3 py-1.5\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chevron-down", "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, "Manage</button></div></div><div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 244, Col: 44}
|
||||
}
|
||||
_, 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, 30, "\"></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div><!-- User Library Visibility Section --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Library Visibility</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Control which libraries are visible to users</p><div id=\"visibility-controls\" class=\"space-y-4\"><!-- Visibility controls will be loaded here --></div></div></div><!-- User Visibility Management --><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">User Library Access</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Manage individual user access to specific libraries</p><div class=\"mb-4\"><select id=\"user-select\" onchange=\"loadUserVisibility()\" class=\"px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\"><option value=\"\">Select a user...</option> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div id=\"scan-indicator\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, user := range users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"{ user.ID }\">")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func LibraryPanel(user User, libraryID string, library LibraryData, folders []FolderData, users []User, visibility []UserVisibilityData, issueCount int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var10 == nil {
|
||||
templ_7745c5c3_Var10 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Folders</h4></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(folders) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<p class=\"text-sm mb-3\" style=\"color: var(--text-secondary)\">No folders configured. Add a folder to enable scanning.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"space-y-2 mb-3\">")
|
||||
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
|
||||
for _, folder := range folders {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<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)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 266, Col: 99}
|
||||
}
|
||||
_, 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, 38, "</code> <button class=\"icon-btn h-7 w-7 shrink-0\" hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 269, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-vals=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"folder_path": "` + folder.FolderPath + `"}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 270, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 271, Col: 49}
|
||||
}
|
||||
_, 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, 41, "\" hx-swap=\"innerHTML\" hx-confirm=\"Remove this folder from the library?\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "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, 42, "</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 81, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</select></div><div id=\"user-libraries\" class=\"space-y-3\"><!-- User library checkboxes will be loaded here --></div></div></div></main></div><!-- Create Library Modal --><div id=\"create-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Library</h2><button type=\"button\" data-action=\"hide-create-modal\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><form id=\"create-library-form\"><input type=\"hidden\" id=\"library-id\" name=\"id\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Library Name</label> <input type=\"text\" name=\"name\" placeholder=\"My Ebook Library\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Description</label> <textarea name=\"description\" placeholder=\"Optional description\" rows=\"3\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\"></textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Library Type</label> <select name=\"type\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required><option value=\"ebooks\">📚 Ebooks</option> <option value=\"comics\">📖 Comics</option> <option value=\"manga\">🗾 Manga</option></select></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" data-action=\"hide-create-modal\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Create</button></div></form></div></div><!-- Folder Browser Modal --><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" data-action=\"browse-cancel\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div id=\"folder-browser-content\"><!-- Directory listings will be rendered here --></div></div></div><!-- Delete Library Confirmation Modal --><div id=\"delete-library-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Delete Library</h2><button type=\"button\" data-action=\"hide-delete-modal\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div id=\"delete-modal-content\" class=\"mb-6\" style=\"color: var(--text-primary)\"><!-- Dynamic content will be injected here --></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" data-action=\"hide-delete-modal\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"button\" data-action=\"confirm-delete\" class=\"btn-primary px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600\">Delete</button></div></div></div><script src=\"/static/htmx.min.js\"></script></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<form class=\"flex gap-2\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 281, Col: 80}
|
||||
}
|
||||
_, 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, 45, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 281, Col: 124}
|
||||
}
|
||||
_, 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, 46, "\" hx-swap=\"innerHTML\"><input type=\"text\" name=\"folder_path\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 285, Col: 37}
|
||||
}
|
||||
_, 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, 47, "\" placeholder=\"/path/to/books\" class=\"input flex-1\" required> <button type=\"button\" data-library-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 292, Col: 32}
|
||||
}
|
||||
_, 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, 48, "\" data-target-input=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 293, Col: 52}
|
||||
}
|
||||
_, 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, 49, "\" onclick=\"openFolderBrowser(this.dataset.targetInput, this.dataset.libraryId)\" class=\"btn btn-secondary text-sm shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Browse</button> <button type=\"submit\" class=\"btn btn-primary text-sm shrink-0\">")
|
||||
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, 51, "Add</button></form></div><!-- User Visibility -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(users) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div><div class=\"flex items-center gap-2 mb-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("users", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<h4 class=\"text-sm font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">User Access</h4></div><div class=\"space-y-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, u := range users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<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\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if isUserVisible(u.ID, visibility) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 322, Col: 63}
|
||||
}
|
||||
_, 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, 57, "\" hx-vals=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 323, Col: 47}
|
||||
}
|
||||
_, 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, 58, "\" hx-trigger=\"change\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 49}
|
||||
}
|
||||
_, 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, 59, "\" hx-swap=\"innerHTML\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 329, Col: 75}
|
||||
}
|
||||
_, 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, 61, "</span></label>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<!-- Processing Issues -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issueCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 templ.SafeURL
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 338, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("alert", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issueCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>1 processing issue</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 343, Col: 24}
|
||||
}
|
||||
_, 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, 68, " processing issues</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<!-- Actions --><div class=\"flex gap-2 pt-2 border-t\" style=\"border-color: var(--border);\"><button data-edit-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 352, Col: 28}
|
||||
}
|
||||
_, 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, 71, "\" data-edit-name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 353, Col: 33}
|
||||
}
|
||||
_, 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, 72, "\" data-edit-desc=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 354, Col: 40}
|
||||
}
|
||||
_, 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, 73, "\" onclick=\"openEditModal(this.dataset.editId, this.dataset.editName, this.dataset.editDesc)\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "Edit Details</button> <button data-delete-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 362, Col: 30}
|
||||
}
|
||||
_, 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, 75, "\" data-delete-name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 363, Col: 35}
|
||||
}
|
||||
_, 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, 76, "\" onclick=\"openDeleteModal(this.dataset.deleteId, this.dataset.deleteName)\" class=\"btn btn-danger text-sm\">")
|
||||
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, 77, "Delete Library</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func FolderBrowserContent(currentPath string, parentPath string, entries []DirEntry, targetInput string, libraryID 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_Var32 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var32 == nil {
|
||||
templ_7745c5c3_Var32 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div><div class=\"flex items-center gap-2 mb-3 p-2 rounded-lg\" style=\"background-color: var(--bg-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<code class=\"text-xs flex-1 truncate\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 378, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</code></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if parentPath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<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=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 384, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||
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, 83, "<span>..</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<div class=\"space-y-1 max-h-64 overflow-y-auto\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<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=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 397, Col: 118}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" hx-target=\"#folder-browser-content\" hx-swap=\"innerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-4 w-4 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<span class=\"truncate\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 402, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><div class=\"mt-4 flex justify-end\"><button type=\"button\" data-target-input=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 409, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" data-current-path=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 410, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" onclick=\"document.getElementById(this.dataset.targetInput).value = this.dataset.currentPath; document.getElementById('folder-browser-modal').classList.add('hidden')\" class=\"btn btn-primary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "Select This Folder</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
package templates
|
||||
|
||||
templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) {
|
||||
@@ -8,87 +7,107 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu
|
||||
<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 x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/libraries/"+libraryID)
|
||||
<main class="flex-1 p-8">
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/library")
|
||||
<main class="p-8">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">Processing Issues</h1>
|
||||
<p class="text-gray-600">Items that couldn't be processed in this library</p>
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Processing Issues</h1>
|
||||
</div>
|
||||
<a href="/admin/libraries/{ libraryID }" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
← Back to Library
|
||||
</a>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Items that couldn't be processed in this library</p>
|
||||
</div>
|
||||
</div>
|
||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
if stats.ErrorCount > 0 {
|
||||
<div class="card p-6 rounded-lg border-l-4 border-red-500">
|
||||
<h3 class="text-lg font-semibold text-red-600 mb-2">Errors</h3>
|
||||
<p class="text-3xl font-bold">{ stats.ErrorCount }</p>
|
||||
</div>
|
||||
}
|
||||
if stats.WarningCount > 0 {
|
||||
<div class="card p-6 rounded-lg border-l-4 border-yellow-500">
|
||||
<h3 class="text-lg font-semibold text-yellow-600 mb-2">Warnings</h3>
|
||||
<p class="text-3xl font-bold">{ stats.WarningCount }</p>
|
||||
</div>
|
||||
}
|
||||
if stats.InfoCount > 0 {
|
||||
<div class="card p-6 rounded-lg border-l-4 border-blue-500">
|
||||
<h3 class="text-lg font-semibold text-blue-600 mb-2">Info</h3>
|
||||
<p class="text-3xl font-bold">{ stats.InfoCount }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
<div class="card p-8 rounded-lg text-center">
|
||||
<p class="text-gray-600">No processing issues found for this library.</p>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Issues List -->
|
||||
<div class="space-y-4">
|
||||
for _, issue := range issues {
|
||||
<div class="card p-6 rounded-lg">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-lg font-semibold mb-2">{ issue.Title }</h4>
|
||||
<p class="text-gray-700 mb-3">{ issue.IssueDescription }</p>
|
||||
<div class="text-sm text-gray-500 space-y-1">
|
||||
<p><strong>Type:</strong> { issue.IssueType }</p>
|
||||
<p><strong>Format:</strong> { issue.FormatGroup }</p>
|
||||
<p><strong>File:</strong> { issue.FilePath }</p>
|
||||
<p><strong>Library:</strong> { issue.LibraryTypeName }</p>
|
||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
if stats.ErrorCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-danger);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-danger);">
|
||||
@Icon("x-circle", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Errors</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.ErrorCount }</p>
|
||||
</div>
|
||||
}
|
||||
if stats.WarningCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-warning);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Warnings</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.WarningCount }</p>
|
||||
</div>
|
||||
}
|
||||
if stats.InfoCount > 0 {
|
||||
<div class="stat-card" style="border-left: 4px solid var(--status-info);">
|
||||
<div class="flex items-center gap-2 mb-2" style="color: var(--status-info);">
|
||||
@Icon("info", "h-5 w-5")
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide">Info</h3>
|
||||
</div>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary)">{ stats.InfoCount }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
<div class="card p-8 text-center">
|
||||
<span class="grid place-items-center h-12 w-12 mx-auto mb-3 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("check-circle", "h-6 w-6")
|
||||
</span>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">No processing issues found for this library.</p>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Issues List -->
|
||||
<div class="space-y-4">
|
||||
for _, issue := range issues {
|
||||
<div class="card p-6" id={ "issue-" + issue.ID }>
|
||||
<div class="flex justify-between items-start gap-4 mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ issue.Title }</h4>
|
||||
<p class="mb-3 text-sm" style="color: var(--text-secondary)">{ issue.IssueDescription }</p>
|
||||
<div class="text-sm space-y-1" style="color: var(--text-secondary)">
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Type:</span> { issue.IssueType }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Format:</span> { issue.FormatGroup }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">File:</span> { issue.FilePath }</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Library:</span> { issue.LibraryTypeName }</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 shrink-0">
|
||||
if issue.Severity == "error" {
|
||||
<span class="badge status-failed">{ issue.Severity }</span>
|
||||
} else if issue.Severity == "warning" {
|
||||
<span class="badge status-pending">{ issue.Severity }</span>
|
||||
} else {
|
||||
<span class="badge status-processing">{ issue.Severity }</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<span
|
||||
class="inline-block px-3 py-1 text-sm rounded-full font-medium"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
{ issue.Severity }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
<button
|
||||
@click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
|
||||
class="btn-secondary px-4 py-2 rounded text-sm"
|
||||
hx-post={ "/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve" }
|
||||
hx-target={ "#issue-" + issue.ID }
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Dismiss this issue?"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("close", "h-4 w-4")
|
||||
Dismiss
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -30,208 +29,341 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><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 }\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(user, "/admin/libraries/"+libraryID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Header(user, "/admin/library").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"flex-1 p-8\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><div><h1 class=\"text-3xl font-bold mb-2\">Processing Issues</h1><p class=\"text-gray-600\">Items that couldn't be processed in this library</p></div><a href=\"/admin/libraries/{ libraryID }\" class=\"btn-secondary px-4 py-2 rounded-lg\">← Back to Library</a></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Processing Issues</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Items that couldn't be processed in this library</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- Stats Cards --> <div class=\"grid grid-cols-1 md:grid-cols-3 gap-6 mb-8\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if stats.ErrorCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"card p-6 rounded-lg border-l-4 border-red-500\"><h3 class=\"text-lg font-semibold text-red-600 mb-2\">Errors</h3><p class=\"text-3xl font-bold\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 32, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if stats.WarningCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card p-6 rounded-lg border-l-4 border-yellow-500\"><h3 class=\"text-lg font-semibold text-yellow-600 mb-2\">Warnings</h3><p class=\"text-3xl font-bold\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 38, Col: 58}
|
||||
}
|
||||
_, 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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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);\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if stats.InfoCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"card p-6 rounded-lg border-l-4 border-blue-500\"><h3 class=\"text-lg font-semibold text-blue-600 mb-2\">Info</h3><p class=\"text-3xl font-bold\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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: 44, Col: 55}
|
||||
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, 9, "</p></div>")
|
||||
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, 10, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-8 rounded-lg text-center\"><p class=\"text-gray-600\">No processing issues found for this library.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<!-- Issues List --> <div class=\"space-y-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, issue := range issues {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card p-6 rounded-lg\"><div class=\"flex justify-between items-start mb-4\"><div class=\"flex-1\"><h4 class=\"text-lg font-semibold mb-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card p-6\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||
templ_7745c5c3_Var5, 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: 60, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, 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, 14, "</h4><p class=\"text-gray-700 mb-3\">")
|
||||
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)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 61, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p><div class=\"text-sm text-gray-500 space-y-1\"><p><strong>Type:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h4><p class=\"mb-3 text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 63, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p><p><strong>Format:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p><div class=\"text-sm space-y-1\" style=\"color: var(--text-secondary)\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Type:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 64, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p><p><strong>File:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Format:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 65, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p><p><strong>Library:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">File:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 66, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p></div></div><div class=\"ml-4\"><span class=\"inline-block px-3 py-1 text-sm rounded-full font-medium\" style=\"background-color: var(--accent); color: white;\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Library:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span></div></div><div class=\"flex gap-3 mt-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p></div></div><div class=\"ml-2 shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button @click=\"dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')\" class=\"btn-secondary px-4 py-2 rounded text-sm\">Dismiss</button>")
|
||||
if issue.Severity == "error" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"badge status-failed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_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, 22, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></div><div class=\"flex gap-3 mt-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if issue.Severity == "warning" || issue.Severity == "info" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button hx-post=\"")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+190
-39
@@ -1,6 +1,8 @@
|
||||
package templates
|
||||
|
||||
templ AdminSettings(user User, systemConfig map[string]string, errorMessage string) {
|
||||
import "fmt"
|
||||
|
||||
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -8,58 +10,63 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
|
||||
<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 }" x-data="adminSettings">
|
||||
@Header(user, "/admin/settings")
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
@AdminSidebar(user, "/admin/settings")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<a href="/admin" class="btn-secondary px-4 py-2 rounded-lg font-medium">
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">System Settings</h1>
|
||||
<p style="color: var(--text-secondary)">Configure your Bookhoard instance</p>
|
||||
</div>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/settings")
|
||||
<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);">
|
||||
@Icon("gear", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">System Settings</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Configure your Bookhoard instance</p>
|
||||
</div>
|
||||
if errorMessage != "" {
|
||||
<div class="mb-6 p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--error); color: var(--error);">
|
||||
{ errorMessage }
|
||||
<div class="mb-6 p-4 rounded-xl border flex items-start gap-3" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); border-color: var(--status-danger); color: var(--status-danger);">
|
||||
@Icon("alert", "h-5 w-5 shrink-0 mt-0.5")
|
||||
<span>{ errorMessage }</span>
|
||||
</div>
|
||||
}
|
||||
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
@Icon("globe", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Base URL</h3>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Base URL</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Base URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="base_url"
|
||||
value={ systemConfig["base_url"] }
|
||||
placeholder="https://books.example.com"
|
||||
class="w-full px-4 py-2 rounded-lg border"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
required
|
||||
/>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
|
||||
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("save", "h-4 w-4")
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">System Defaults</h3>
|
||||
<div class="mt-6 card p-6">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
@Icon("clock", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">System Defaults</h3>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default Timezone</label>
|
||||
<select
|
||||
name="default_timezone"
|
||||
id="default_timezone"
|
||||
class="w-full px-4 py-2 rounded-lg border"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="UTC" selected?={ systemConfig["default_timezone"] == "UTC" }>UTC (UTC+0)</option>
|
||||
<option value="Pacific/Honolulu" selected?={ systemConfig["default_timezone"] == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
|
||||
@@ -86,21 +93,165 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri
|
||||
<option value="Australia/Sydney" selected?={ systemConfig["default_timezone"] == "Australia/Sydney" }>Australian Eastern (UTC+10/+11)</option>
|
||||
<option value="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>New Zealand (UTC+12/+13)</option>
|
||||
</select>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||||
<p class="text-sm mt-2" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">URL Paths</h3>
|
||||
<div class="mt-6 card p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
@Icon("external", "h-5 w-5 shrink-0")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">URL Paths</h3>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
|
||||
<p><strong>OPDS:</strong> { systemConfig["base_url"] }/opds</p>
|
||||
<p><strong>API:</strong> { systemConfig["base_url"] }/api</p>
|
||||
<p><strong>Device Sync:</strong> { systemConfig["base_url"] }/api/sync</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">OPDS:</span> { systemConfig["base_url"] }/opds</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">API:</span> { systemConfig["base_url"] }/api</p>
|
||||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Device Sync:</span> { systemConfig["base_url"] }/api/sync</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@ScanSettingsSection(scanSettings)
|
||||
@TunableSettingsSection(liveGroups, false)
|
||||
@TunableSettingsSection(restartGroups, true)
|
||||
</div>
|
||||
</body>
|
||||
</main>
|
||||
</body>
|
||||
</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 (1–3600 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>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ package templates
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func AdminSettings(user User, systemConfig map[string]string, errorMessage string) templ.Component {
|
||||
import "fmt"
|
||||
|
||||
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) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -29,7 +31,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><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\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,330 +39,792 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = AdminSidebar(user, "/admin/settings").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("gear", "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, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between mb-4\"><a href=\"/admin\" class=\"btn-secondary px-4 py-2 rounded-lg font-medium\">← Back to Dashboard</a></div><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">System Settings</h1><p style=\"color: var(--text-secondary)\">Configure your Bookhoard instance</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">System Settings</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Configure your Bookhoard instance</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errorMessage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"mb-6 p-4 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--error); color: var(--error);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 29, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 31, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<form id=\"settings-form\" hx-put=\"/api/system/config\" hx-target=\"#settings-form\" hx-swap=\"outerHTML\"><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">Base URL</h3><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Base URL</label> <input type=\"url\" name=\"base_url\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 40, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 45, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" placeholder=\"https://books.example.com\" class=\"w-full px-4 py-2 rounded-lg border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" required><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p></div><div class=\"mt-6 flex justify-end\"><button type=\"submit\" class=\"btn-primary px-6 py-2 rounded-lg font-medium\">Save Settings</button></div></div><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-6\" style=\"color: var(--text-primary)\">System Defaults</h3><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-primary)\">Default Timezone</label> <select name=\"default_timezone\" id=\"default_timezone\" class=\"w-full px-4 py-2 rounded-lg border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"UTC\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "UTC" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Anchorage" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Los_Angeles" {
|
||||
if systemConfig["default_timezone"] == "Pacific/Honolulu" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Denver" {
|
||||
if systemConfig["default_timezone"] == "America/Anchorage" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Phoenix" {
|
||||
if systemConfig["default_timezone"] == "America/Los_Angeles" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Chicago" {
|
||||
if systemConfig["default_timezone"] == "America/Denver" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/New_York" {
|
||||
if systemConfig["default_timezone"] == "America/Phoenix" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
|
||||
if systemConfig["default_timezone"] == "America/Chicago" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Europe/London" {
|
||||
if systemConfig["default_timezone"] == "America/New_York" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Europe/Paris" {
|
||||
if systemConfig["default_timezone"] == "America/Sao_Paulo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Europe/Helsinki" {
|
||||
if systemConfig["default_timezone"] == "Europe/London" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Europe/Moscow" {
|
||||
if systemConfig["default_timezone"] == "Europe/Paris" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Tehran" {
|
||||
if systemConfig["default_timezone"] == "Europe/Helsinki" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Dubai" {
|
||||
if systemConfig["default_timezone"] == "Europe/Moscow" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Karachi" {
|
||||
if systemConfig["default_timezone"] == "Asia/Tehran" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Kolkata" {
|
||||
if systemConfig["default_timezone"] == "Asia/Dubai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Dhaka" {
|
||||
if systemConfig["default_timezone"] == "Asia/Karachi" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Bangkok" {
|
||||
if systemConfig["default_timezone"] == "Asia/Kolkata" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Shanghai" {
|
||||
if systemConfig["default_timezone"] == "Asia/Dhaka" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Asia/Tokyo" {
|
||||
if systemConfig["default_timezone"] == "Asia/Bangkok" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Australia/Darwin" {
|
||||
if systemConfig["default_timezone"] == "Asia/Shanghai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Australia/Sydney" {
|
||||
if systemConfig["default_timezone"] == "Asia/Tokyo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Pacific/Auckland" {
|
||||
if systemConfig["default_timezone"] == "Australia/Darwin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">New Zealand (UTC+12/+13)</option></select><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Default timezone for users who haven't set their own.</p></div></div></form><div class=\"mt-8 card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">URL Paths</h3><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><strong>OPDS:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Australia/Sydney" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if systemConfig["default_timezone"] == "Pacific/Auckland" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 96, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 145}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "/opds</p><p><strong>API:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "/opds</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">API:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 97, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "/api</p><p><strong>Device Sync:</strong> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "/api</p><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Device Sync:</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 98, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 108, Col: 152}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "/api/sync</p></div></div></div></main></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/api/sync</p></div></div>")
|
||||
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 (1–3600 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package templates
|
||||
|
||||
templ AdminSidebar(user User, currentPath string) {
|
||||
<aside class="w-64 border-r" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold mb-6" style="color: var(--text-primary)">Admin Panel</h2>
|
||||
<nav class="space-y-2">
|
||||
<a href="/admin" class={activeClass(currentPath, "/admin")} style="color: var(--text-primary)">
|
||||
🏠 Dashboard
|
||||
</a>
|
||||
<a href="/admin/users" class={activeClass(currentPath, "/admin/users")} style="color: var(--text-primary)">
|
||||
👤 User Administration
|
||||
</a>
|
||||
<a href="/admin/library" class={activeClass(currentPath, "/admin/library")} style="color: var(--text-primary)">
|
||||
📚 Library Management
|
||||
</a>
|
||||
<a href="/admin/settings" class={activeClass(currentPath, "/admin/settings")} style="color: var(--text-primary)">
|
||||
⚙️ System Settings
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// 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-64 border-r\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"p-6\"><h2 class=\"text-lg font-semibold mb-6\" style=\"color: var(--text-primary)\">Admin Panel</h2><nav class=\"space-y-2\">")
|
||||
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, 2, "<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, 3, "\" style=\"color: var(--text-primary)\">🏠 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, 4, "<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, 5, "\" style=\"color: var(--text-primary)\">👤 User Administration</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, 6, "<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, 7, "\" style=\"color: var(--text-primary)\">📚 Library Management</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, 8, "<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, 9, "\" style=\"color: var(--text-primary)\">⚙️ System Settings</a></nav></div></aside>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
+137
-5
@@ -8,7 +8,7 @@ package templates
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func Admin(user User) templ.Component {
|
||||
func Admin(user User, stats AdminStats) 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 {
|
||||
@@ -29,7 +29,7 @@ func Admin(user User) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><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\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,15 +37,147 @@ func Admin(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = AdminSidebar(user, "/admin").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("grid", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"max-w-4xl\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Dashboard</h1><p style=\"color: var(--text-secondary)\">Overview of your Bookhoard library and settings</p></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">📖</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Library</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Manage your ebook collection</p></div></div><a href=\"/\" class=\"mt-4 inline-block text-sm btn-secondary px-3 py-1 rounded\">View Library</a></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><div class=\"flex items-center space-x-3\"><div class=\"text-3xl\">👁️</div><div><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Watch Status</h3><p style=\"color: var(--text-secondary)\" class=\"text-sm\">Auto-detecting new files</p></div></div><div id=\"watch-status\" class=\"mt-4 text-sm\" style=\"color: var(--text-secondary)\"><span class=\"inline-block w-2 h-2 rounded-full bg-green-500 mr-2\"></span> Watching <span id=\"watch-count\">0</span> libraries</div></div></div><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Quick Actions</h3><div class=\"grid grid-cols-1 sm:grid-cols-2 gap-4\"><button @click=\"scanAllLibraries()\" class=\"btn-primary p-4 rounded-lg text-left\"><div class=\"font-medium\">Rescan Library</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Re-scan existing files and fix metadata</div></button> <a href=\"/admin/library\" class=\"btn-secondary p-4 rounded-lg text-left block\"><div class=\"font-medium\">Manage Libraries and Folders</div><div style=\"color: var(--text-secondary)\" class=\"text-sm\">Add or remove libraries and scan directories</div></a></div></div><!-- Scan Progress Section --><div id=\"scan-progress-container\" class=\"hidden mt-6 p-6 rounded-lg border opacity-0 -translate-y-2.5 transition-all duration-300 ease-out\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-4\"><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">📚 Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"p-2 hover:bg-gray-700 rounded\">✕</button></div><!-- Overall Progress --><div class=\"mb-4\"><div class=\"flex justify-between text-sm mb-2\"><span style=\"color: var(--text-secondary)\">Overall Progress</span> <span id=\"scan-progress-text\" style=\"color: var(--text-primary)\">0%</span></div><div class=\"w-full bg-gray-700 rounded-full h-3\"><div id=\"scan-progress-bar\" class=\"h-3 rounded-full transition-all duration-500\" style=\"width: 0%; background-color: var(--accent);\"></div></div><div id=\"scan-status\" class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">Starting scan...</div></div><!-- Per-Library Progress --><div id=\"library-progress-list\" class=\"space-y-3\"><!-- Dynamically populated --></div><!-- Results Summary --><div id=\"scan-results\" class=\"hidden mt-6 p-4 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><h4 class=\"font-semibold mb-2\" style=\"color: var(--text-primary)\">✅ Scan Complete!</h4><div id=\"scan-results-content\" style=\"color: var(--text-secondary)\"><!-- Results populated by JS --></div><div class=\"mt-4 flex gap-2\"><button @click=\"window.location.reload()\" class=\"btn-primary px-4 py-2 rounded-lg\">Refresh to View Books</button> <button @click=\"hideScanProgress()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Dismiss</button></div></div></div></div></main></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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);\">")
|
||||
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, 4, "<span class=\"text-xs font-semibold uppercase tracking-wide\">Libraries</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_Var2 string
|
||||
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 {
|
||||
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);\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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)\">")
|
||||
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, 15, "Scanning Libraries</h3><button @click=\"hideScanProgress()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+121
-117
@@ -8,127 +8,131 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
<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 }">
|
||||
@Header(currentUser, "/admin/users")
|
||||
<!-- Modal Container (populated by HTMX) -->
|
||||
<div id="modal-container"></div>
|
||||
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
|
||||
@AdminSidebar(currentUser, "/admin/users")
|
||||
<main class="flex-1 p-8">
|
||||
<div class="w-full">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1>
|
||||
<p style="color: var(--text-secondary)">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
<!-- Users Table -->
|
||||
<div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<table class="w-full">
|
||||
<thead style="background-color: var(--bg-primary)">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Role</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Created</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y" style="divide-color: var(--border)">
|
||||
for _, user := range users {
|
||||
<tr id={ "user-" + user.ID } class="hover:bg-opacity-50" style="transition: background-color 0.2s;">
|
||||
<!-- Username -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
|
||||
if user.ID == currentUser.ID {
|
||||
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span>
|
||||
@Header(currentUser, "/admin/users")
|
||||
<!-- 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);">
|
||||
@Icon("users", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">User Management</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
<!-- Users Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead style="background-color: var(--bg-primary)">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Role</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Created</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y" style="divide-color: var(--border)">
|
||||
for _, user := range users {
|
||||
<tr id={ "user-" + user.ID } class="transition-colors hover:bg-surface-hover">
|
||||
<!-- Username -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="grid place-items-center h-8 w-8 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
|
||||
if user.ID == currentUser.ID {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Email -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
|
||||
</td>
|
||||
<!-- Role Toggle (with last-admin protection) -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<!-- Last admin - disabled -->
|
||||
<div class="relative">
|
||||
<select
|
||||
disabled
|
||||
class="input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed"
|
||||
title="Cannot demote the last admin"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin" selected>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
} else {
|
||||
<select
|
||||
hx-put={ "/api/auth/profile/" + user.ID }
|
||||
hx-target={ "#role-result-" + user.ID }
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="change"
|
||||
name="role"
|
||||
class="input w-auto py-1 pr-7 text-xs"
|
||||
>
|
||||
<option value="user" selected?={ user.Role == "user" }>User</option>
|
||||
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
||||
</select>
|
||||
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
|
||||
}
|
||||
</td>
|
||||
<!-- Created -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary text-xs px-2.5 py-1"
|
||||
>
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit
|
||||
</button>
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<button
|
||||
disabled
|
||||
class="btn btn-danger text-xs px-2.5 py-1"
|
||||
title="Cannot delete the last admin"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
hx-delete={ "/api/auth/profile/" + user.ID }
|
||||
hx-target={ "#user-" + user.ID }
|
||||
hx-swap="outerHTML swap:0.5s"
|
||||
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||
class="btn btn-danger text-xs px-2.5 py-1"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Email -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
|
||||
</td>
|
||||
<!-- Role Toggle (with last-admin protection) -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<!-- Last admin - disabled -->
|
||||
<div class="relative">
|
||||
<select
|
||||
disabled
|
||||
class="text-sm rounded px-2 py-1 cursor-not-allowed opacity-50"
|
||||
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
|
||||
title="Cannot demote the last admin"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin" selected>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
} else {
|
||||
<select
|
||||
hx-put={ "/api/auth/profile/" + user.ID }
|
||||
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
|
||||
hx-target={ "#role-result-" + user.ID }
|
||||
hx-swap="innerHTML"
|
||||
name="role"
|
||||
class="text-sm rounded px-2 py-1"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
onchange="this.dispatchEvent(new Event('htmx:trigger'))"
|
||||
hx-trigger="change"
|
||||
hx-vals='{"role": this.value}'
|
||||
>
|
||||
<option value="user" selected?={ user.Role == "user" }>User</option>
|
||||
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
||||
</select>
|
||||
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
|
||||
}
|
||||
</td>
|
||||
<!-- Created -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm px-3 py-1 rounded mr-2"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<button
|
||||
disabled
|
||||
class="text-sm px-3 py-1 rounded cursor-not-allowed opacity-50"
|
||||
style="background-color: #dc2626; color: white;"
|
||||
title="Cannot delete the last admin"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
hx-delete={ "/api/auth/profile/" + user.ID }
|
||||
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
|
||||
hx-target={ "#user-" + user.ID }
|
||||
hx-swap="outerHTML swap:0.5s"
|
||||
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||
class="text-sm px-3 py-1 rounded"
|
||||
style="background-color: #dc2626; color: white;"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><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 }\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,15 +37,15 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><div class=\"flex min-h-screen\" style=\"background-color: var(--bg-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container (populated by HTMX) --><div id=\"modal-container\"></div><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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = AdminSidebar(currentUser, "/admin/users").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Icon("users", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<main class=\"flex-1 p-8\"><div class=\"w-full\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">User Management</h1><p style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card rounded-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><table class=\"w-full\"><thead style=\"background-color: var(--bg-primary)\"><tr><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Username</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Email</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Role</th><th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Created</th><th class=\"px-6 py-3 text-right text-xs font-medium uppercase tracking-wider\" style=\"color: var(--text-secondary)\">Actions</th></tr></thead> <tbody class=\"divide-y\" style=\"divide-color: var(--border)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">User Management</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Manage user accounts and permissions</p></div><!-- Users Table --><div class=\"card overflow-hidden\"><table class=\"w-full\"><thead style=\"background-color: var(--bg-primary)\"><tr><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Username</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Email</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Role</th><th class=\"px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Created</th><th class=\"px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Actions</th></tr></thead> <tbody class=\"divide-y\" style=\"divide-color: var(--border)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -57,195 +57,227 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"hover:bg-opacity-50\" style=\"transition: background-color 0.2s;\"><!-- Username --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"flex items-center\"><div><div class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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);\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.ID == currentUser.ID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"text-xs px-2 py-1 rounded\" style=\"background-color: var(--accent); color: white;\">You</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">You</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></div></td><!-- Email --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 51, Col: 79}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></td><!-- Role Toggle (with last-admin protection) --><td class=\"px-6 py-4 whitespace-nowrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!-- Last admin - disabled --> <div class=\"relative\"><select disabled class=\"text-sm rounded px-2 py-1 cursor-not-allowed opacity-50\" style=\"background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<select hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<select hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 70, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 72, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-swap=\"innerHTML\" name=\"role\" class=\"text-sm rounded px-2 py-1\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\" onchange=\"this.dispatchEvent(new Event('htmx:trigger'))\" hx-trigger=\"change\" hx-vals='{\"role\": this.value}'><option value=\"user\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" hx-swap=\"innerHTML\" hx-trigger=\"change\" name=\"role\" class=\"input w-auto py-1 pr-7 text-xs\"><option value=\"user\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "user" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
||||
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, 15, ">User</option> <option value=\"admin\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">User</option> <option value=\"admin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
||||
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, 17, ">Admin</option></select><div id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">Admin</option></select><div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 84, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"text-xs mt-1\"></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"text-xs mt-1\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><!-- Created --><td class=\"px-6 py-4 whitespace-nowrap\"><div class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 89, Col: 125}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></td><!-- Actions --><td class=\"px-6 py-4 whitespace-nowrap text-right\"><button hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 94, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"text-sm px-3 py-1 rounded mr-2\" style=\"background-color: var(--accent); color: white;\">Edit</button> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-xs px-2.5 py-1\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Edit</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button disabled class=\"text-sm px-3 py-1 rounded cursor-not-allowed opacity-50\" style=\"background-color: #dc2626; color: white;\" title=\"Cannot delete the last admin\">Delete</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button disabled class=\"btn btn-danger text-xs px-2.5 py-1\" title=\"Cannot delete the last admin\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Delete</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button hx-delete=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 113, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"text-sm px-3 py-1 rounded\" style=\"background-color: #dc2626; color: white;\">Delete</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">")
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</tbody></table></div></main></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</tbody></table></div></div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+36
-38
@@ -9,81 +9,79 @@ templ Analytics(user User) {
|
||||
<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 }">
|
||||
@Header(user, "/analytics")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">📊 Reading Analytics</h1>
|
||||
<p style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("chart", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Analytics</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Track your reading habits and device usage</p>
|
||||
</div>
|
||||
<!-- Date Range Picker -->
|
||||
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="card p-4 mb-6">
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Start Date</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
onchange="loadAnalytics()"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">End Date</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
onchange="loadAnalytics()"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button @click="loadAnalytics()" class="btn-primary px-6 py-2 rounded-lg">Update</button>
|
||||
<div>
|
||||
<button @click="loadAnalytics()" class="btn btn-primary">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8" id="stats-container">
|
||||
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Books Read</h3>
|
||||
<p id="total-books" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8" id="stats-container">
|
||||
<div class="stat-card">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Books Read</h3>
|
||||
<p id="total-books" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||
</div>
|
||||
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
||||
<p id="total-pages" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
<div class="stat-card">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Pages Read</h3>
|
||||
<p id="total-pages" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||
</div>
|
||||
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
||||
<p id="reading-time" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
<div class="stat-card">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Reading Time</h3>
|
||||
<p id="reading-time" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||
</div>
|
||||
<div class="card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
|
||||
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--text-primary);">-</p>
|
||||
<div class="stat-card">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Completion Rate</h3>
|
||||
<p id="completion-rate" class="text-3xl font-bold" style="color: var(--accent);">-</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Charts Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Daily Reading Chart -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Daily Reading Minutes</h3>
|
||||
<div class="card p-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Daily Reading Minutes</h3>
|
||||
<div class="h-80">
|
||||
<canvas id="daily-reading-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Usage Chart -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Device Usage</h3>
|
||||
<div class="card p-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Device Usage</h3>
|
||||
<div class="h-80">
|
||||
<canvas id="device-usage-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Popular Books -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Most Read Books</h3>
|
||||
<div class="card p-6">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary);">Most Read Books</h3>
|
||||
<div id="popular-books" class="space-y-3">
|
||||
<div class="text-center py-8" style="color: var(--text-secondary);">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
|
||||
@@ -29,7 +29,7 @@ func Analytics(user User) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>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 }\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,7 +37,15 @@ func Analytics(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">📊 Reading Analytics</h1><p style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><!-- Date Range Picker --><div class=\"card p-4 rounded-lg border mb-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div class=\"flex items-end\"><button @click=\"loadAnalytics()\" class=\"btn-primary px-6 py-2 rounded-lg\">Update</button></div></div></div><!-- Stats Cards --><div class=\"grid grid-cols-1 md:grid-cols-4 gap-6 mb-8\" id=\"stats-container\"><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div><div class=\"card p-6 rounded-lg border transition-all duration-200 ease hover:-translate-y-0.5 hover:shadow-lg\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--text-primary);\">-</p></div></div><!-- Charts Row --><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><!-- Daily Reading Chart --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><!-- Device Usage Chart --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><!-- Popular Books --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chart", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Reading Analytics</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track your reading habits and device usage</p></div><div class=\"card p-4 mb-6\"><div class=\"flex flex-wrap gap-4 items-end\"><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Start Date</label> <input type=\"date\" id=\"start-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div class=\"flex-1 min-w-[200px]\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">End Date</label> <input type=\"date\" id=\"end-date\" onchange=\"loadAnalytics()\" class=\"input\"></div><div><button @click=\"loadAnalytics()\" class=\"btn btn-primary\">Update</button></div></div></div><div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8\" id=\"stats-container\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Books Read</h3><p id=\"total-books\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Pages Read</h3><p id=\"total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Reading Time</h3><p id=\"reading-time\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary);\">Completion Rate</h3><p id=\"completion-rate\" class=\"text-3xl font-bold\" style=\"color: var(--accent);\">-</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Daily Reading Minutes</h3><div class=\"h-80\"><canvas id=\"daily-reading-chart\"></canvas></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Device Usage</h3><div class=\"h-80\"><canvas id=\"device-usage-chart\"></canvas></div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary);\">Most Read Books</h3><div id=\"popular-books\" class=\"space-y-3\"><div class=\"text-center py-8\" style=\"color: var(--text-secondary);\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading analytics...</p></div></div></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -2,64 +2,97 @@ package templates
|
||||
|
||||
templ APIExplorer(explorer APIExplorerData) {
|
||||
if !explorer.IsLoggedIn {
|
||||
// Show mode toggle and mock data
|
||||
<div x-data="devices" class="border border-border rounded-lg p-6 mt-8 bg-background-secondary">
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer bg-accent text-white">Mock Data</button>
|
||||
<button class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button>
|
||||
<div x-data="devices" class="card p-6 mt-8 space-y-5">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
|
||||
} else if explorer.Endpoint.Method == "DELETE" {
|
||||
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
|
||||
} else if explorer.Endpoint.Method == "POST" {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
|
||||
} else {
|
||||
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
|
||||
}
|
||||
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-4">
|
||||
<select disabled class="w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent">
|
||||
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
||||
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
||||
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
||||
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body (Example)</h4>
|
||||
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.RequestBody }</code></pre>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary">Mock Data</button>
|
||||
<button class="btn btn-secondary opacity-50 cursor-not-allowed" disabled>Login to Try Real</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<h4 class="text-text-secondary mb-2 text-sm font-medium">Response (Mock)</h4>
|
||||
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code class="text-text-primary text-sm">{ explorer.Endpoint.Response }</code></pre>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body (Example)</p>
|
||||
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.RequestBody }</code></pre>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
|
||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Response (Mock)</p>
|
||||
<pre class="card p-4 overflow-x-auto"><code class="text-sm" style="color: var(--text-primary);">{ explorer.Endpoint.Response }</code></pre>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))" class="btn btn-secondary text-sm">
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy Request
|
||||
</button>
|
||||
<button @click="copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))" class="btn btn-secondary text-sm">
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy Response
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
// Show interactive explorer with real execution
|
||||
<div class="border border-border rounded-lg p-6 mt-8 bg-background-secondary" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button class="mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
|
||||
<button class="mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
|
||||
<div class="card p-6 mt-8 space-y-5" x-data="apiExplorerDoc" x-init="initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
|
||||
} else if explorer.Endpoint.Method == "DELETE" {
|
||||
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
|
||||
} else if explorer.Endpoint.Method == "POST" {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
|
||||
} else {
|
||||
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
|
||||
}
|
||||
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-4">
|
||||
<select id="http-method" class="w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent">
|
||||
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
||||
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
||||
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
||||
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
||||
</select>
|
||||
<div class="flex gap-2">
|
||||
<button class="mode-btn btn btn-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
|
||||
<button class="mode-btn btn btn-primary text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="http-method" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Method</label>
|
||||
<select id="http-method" class="input">
|
||||
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
|
||||
<option value="POST" selected?={ explorer.Endpoint.Method == "POST" }>POST</option>
|
||||
<option value="PUT" selected?={ explorer.Endpoint.Method == "PUT" }>PUT</option>
|
||||
<option value="DELETE" selected?={ explorer.Endpoint.Method == "DELETE" }>DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="request-body" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Request Body</label>
|
||||
<textarea id="request-body" placeholder="Edit request body…" class="input min-h-[150px] font-mono">{ explorer.Endpoint.RequestBody }</textarea>
|
||||
</div>
|
||||
<button id="try-it-out" @click="tryDocEndpoint()" class="btn btn-primary">
|
||||
@Icon("play", "h-4 w-4")
|
||||
Try It Out
|
||||
</button>
|
||||
<div class="api-response hidden space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span id="response-status" class="font-semibold" style="color: var(--text-primary);"></span>
|
||||
<span id="response-time" style="color: var(--text-secondary);"></span>
|
||||
</div>
|
||||
<h4 class="text-text-secondary mb-2 text-sm font-medium">Request Body</h4>
|
||||
<textarea id="request-body" placeholder="Edit request body..." class="w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent">{ explorer.Endpoint.RequestBody }</textarea>
|
||||
<pre class="card p-4 overflow-x-auto"><code id="response-body" class="text-sm" style="color: var(--text-primary);"></code></pre>
|
||||
</div>
|
||||
<button id="try-it-out" @click="tryDocEndpoint()" class="bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90">Try It Out</button>
|
||||
<div class="api-response mt-4 hidden">
|
||||
<div class="flex justify-between mb-2 text-sm">
|
||||
<span id="response-status" class="font-semibold"></span>
|
||||
<span id="response-time" class="text-text-secondary"></span>
|
||||
</div>
|
||||
<pre class="bg-background-primary p-4 rounded-lg overflow-x-auto"><code id="response-body" class="text-text-primary text-sm"></code></pre>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4 flex-wrap">
|
||||
<button @click="copyDocRequest()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Request</button>
|
||||
<button @click="copyDocResponse()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Copy Response</button>
|
||||
<button @click="generateDocCURL()" class="px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm">Generate cURL</button>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button @click="copyDocRequest()" class="btn btn-secondary text-sm">
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy Request
|
||||
</button>
|
||||
<button @click="copyDocResponse()" class="btn btn-secondary text-sm">
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy Response
|
||||
</button>
|
||||
<button @click="generateDocCURL()" class="btn btn-secondary text-sm">
|
||||
@Icon("external", "h-4 w-4")
|
||||
Generate cURL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+238
-50
@@ -30,131 +30,319 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if !explorer.IsLoggedIn {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " <div x-data=\"devices\" class=\"border border-border rounded-lg p-6 mt-8 bg-background-secondary\"><div class=\"flex gap-2 mb-4\"><button class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer bg-accent text-white\">Mock Data</button> <button class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary opacity-50 cursor-not-allowed\" disabled>Login to Try Real</button></div><div class=\"mb-4\"><div class=\"mb-4\"><select disabled class=\"w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent\"><option value=\"GET\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"devices\" class=\"card p-6 mt-8 space-y-5\"><div class=\"flex items-center gap-2 flex-wrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"badge status-completed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"badge status-failed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge status-processing\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ">GET</option> <option value=\"POST\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">POST</option> <option value=\"PUT\"")
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "PUT" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ">PUT</option> <option value=\"DELETE\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</code></div><div class=\"flex gap-2\"><button class=\"btn btn-primary\">Mock Data</button> <button class=\"btn btn-secondary opacity-50 cursor-not-allowed\" disabled>Login to Try Real</button></div><div><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Request Body (Example)</p><pre class=\"card p-4 overflow-x-auto\"><code class=\"text-sm\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">DELETE</option></select></div><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Request Body (Example)</h4><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code class=\"text-text-primary text-sm\">")
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 21, Col: 141}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</code></pre></div><div><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Response (Mock)</p><pre class=\"card p-4 overflow-x-auto\"><code class=\"text-sm\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</code></pre></div><div class=\"mt-4\"><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Response (Mock)</h4><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code class=\"text-text-primary text-sm\">")
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 25, Col: 138}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</code></pre></div><div class=\"flex gap-2\"><button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</code></pre></div><div class=\"flex gap-2 mt-4\"><button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.RequestBody }, null, 2))\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Response</button></div></div>")
|
||||
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Copy Request</button> <button @click=\"copyToClipboard(JSON.stringify({ explorer.Endpoint.Response }, null, 2))\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Copy Response</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " <div class=\"border border-border rounded-lg p-6 mt-8 bg-background-secondary\" x-data=\"apiExplorerDoc\" x-init=\"initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')\"><div class=\"flex gap-2 mb-4\"><button class=\"mode-btn px-4 py-2 border border-border rounded bg-background-primary text-text-primary cursor-pointer hover:bg-background-secondary text-sm\" id=\"mock-btn\" @click=\"showDocMode('mock')\">Mock Data</button> <button class=\"mode-btn px-4 py-2 bg-accent text-white rounded cursor-pointer text-sm\" id=\"real-btn\" @click=\"showDocMode('real')\">Try It Out</button></div><div class=\"mb-4\"><div class=\"mb-4\"><select id=\"http-method\" class=\"w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent\"><option value=\"GET\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card p-6 mt-8 space-y-5\" x-data=\"apiExplorerDoc\" x-init=\"initAPIExplorerDoc('{ explorer.Endpoint.Path }', '{ explorer.Endpoint.RequestBody }', '{ explorer.Endpoint.Response }')\"><div class=\"flex items-center gap-2 flex-wrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge status-completed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"badge status-failed\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"badge status-processing\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">GET</option> <option value=\"POST\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<code class=\"text-sm\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</code></div><div class=\"flex gap-2\"><button class=\"mode-btn btn btn-secondary text-sm\" id=\"mock-btn\" @click=\"showDocMode('mock')\">Mock Data</button> <button class=\"mode-btn btn btn-primary text-sm\" id=\"real-btn\" @click=\"showDocMode('real')\">Try It Out</button></div><div><label for=\"http-method\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Method</label> <select id=\"http-method\" class=\"input\"><option value=\"GET\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "GET" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">GET</option> <option value=\"POST\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "POST" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">POST</option> <option value=\"PUT\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">POST</option> <option value=\"PUT\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "PUT" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">PUT</option> <option value=\"DELETE\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">PUT</option> <option value=\"DELETE\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if explorer.Endpoint.Method == "DELETE" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">DELETE</option></select></div><h4 class=\"text-text-secondary mb-2 text-sm font-medium\">Request Body</h4><textarea id=\"request-body\" placeholder=\"Edit request body...\" class=\"w-full min-h-[150px] px-3 py-2 font-mono text-sm bg-background-primary text-text-primary border border-border rounded focus:outline-none focus:ring-2 focus:ring-accent\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">DELETE</option></select></div><div><label for=\"request-body\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary);\">Request Body</label> <textarea id=\"request-body\" placeholder=\"Edit request body…\" class=\"input min-h-[150px] font-mono\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 274}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"bg-accent text-white px-6 py-3 rounded cursor-pointer mt-4 hover:opacity-90\">Try It Out</button><div class=\"api-response mt-4 hidden\"><div class=\"flex justify-between mb-2 text-sm\"><span id=\"response-status\" class=\"font-semibold\"></span> <span id=\"response-time\" class=\"text-text-secondary\"></span></div><pre class=\"bg-background-primary p-4 rounded-lg overflow-x-auto\"><code id=\"response-body\" class=\"text-text-primary text-sm\"></code></pre></div><div class=\"flex gap-2 mt-4 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Request</button> <button @click=\"copyDocResponse()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Copy Response</button> <button @click=\"generateDocCURL()\" class=\"px-4 py-2 border border-border rounded bg-background-primary text-text-primary hover:bg-background-secondary cursor-pointer text-sm\">Generate cURL</button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</textarea></div><button id=\"try-it-out\" @click=\"tryDocEndpoint()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("play", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Try It Out</button><div class=\"api-response hidden space-y-2\"><div class=\"flex justify-between text-sm\"><span id=\"response-status\" class=\"font-semibold\" style=\"color: var(--text-primary);\"></span> <span id=\"response-time\" style=\"color: var(--text-secondary);\"></span></div><pre class=\"card p-4 overflow-x-auto\"><code id=\"response-body\" class=\"text-sm\" style=\"color: var(--text-primary);\"></code></pre></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"copyDocRequest()\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Copy Request</button> <button @click=\"copyDocResponse()\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("copy", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Copy Response</button> <button @click=\"generateDocCURL()\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("external", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Generate cURL</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+265
-429
@@ -18,568 +18,405 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
<title>{ book.Title } - 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="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) } data-conflict-id={ conflictID(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }>
|
||||
<body
|
||||
x-data="bookDetail"
|
||||
class="theme-{ user.Theme }"
|
||||
data-format-group={ book.FormatGroup }
|
||||
data-library-id={ uuidToString(book.LibraryID) }
|
||||
data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) }
|
||||
data-conflict-id={ conflictID(book.ActiveConflict) }
|
||||
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
|
||||
>
|
||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||
<div class="w-full px-4 py-3 flex items-center gap-4">
|
||||
<a id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
← Back
|
||||
<!-- Hero with blurred cover backdrop -->
|
||||
<div class="relative overflow-hidden">
|
||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div>
|
||||
}
|
||||
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
|
||||
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
<span>Back</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Top Section: Cover + Basic Info + Actions -->
|
||||
<div class="flex flex-col md:flex-row gap-8 mb-8">
|
||||
<!-- Left: Cover Image (256x384px) -->
|
||||
<div class="flex-shrink-0">
|
||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath.String }
|
||||
alt={ book.Title }
|
||||
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="{ book.Title }"
|
||||
class="w-64 h-96 object-cover rounded-lg shadow-xl"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<!-- Right: Details -->
|
||||
<div class="flex-1">
|
||||
<!-- Title & Author -->
|
||||
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
||||
if book.Author.Valid && book.Author.String != "" {
|
||||
<h2 class="text-2xl mb-6" style="color: var(--text-secondary)">
|
||||
by { book.Author.String }
|
||||
</h2>
|
||||
}
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<!-- Read Now (placeholder) -->
|
||||
<button
|
||||
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
|
||||
class="px-6 py-3 rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
📖 Read Now
|
||||
</button>
|
||||
<!-- Mark as Read / Unread toggle -->
|
||||
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
||||
<button
|
||||
@click="toggleRead(false)"
|
||||
:disabled="readSaving"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
<span x-show="!readSaving">✅ Mark as Unread</span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<!-- Cover -->
|
||||
<div class="flex-shrink-0 mx-auto md:mx-0">
|
||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath.String }
|
||||
alt={ book.Title }
|
||||
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<button
|
||||
@click="toggleRead(true)"
|
||||
:disabled="readSaving"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
<span x-show="!readSaving">📖 Mark as Read</span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt={ book.Title }
|
||||
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
/>
|
||||
}
|
||||
<!-- Sync Progress -->
|
||||
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
||||
<button
|
||||
@click="showProgressSyncModal()"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
🔄 Sync Progress
|
||||
</button>
|
||||
</div>
|
||||
<!-- Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
||||
if book.Author.Valid && book.Author.String != "" {
|
||||
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||
by { book.Author.String }
|
||||
</h2>
|
||||
}
|
||||
<!-- View Notes/Highlights -->
|
||||
<button
|
||||
@click="showNotesModal()"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
📝 Notes & Highlights
|
||||
if book.NotesCount + book.HighlightsCount > 0 {
|
||||
<span
|
||||
class="ml-2 px-2 py-0.5 rounded text-xs font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
{ book.NotesCount + book.HighlightsCount }
|
||||
</span>
|
||||
<!-- Action buttons -->
|
||||
<div class="flex flex-wrap gap-2.5 mb-6">
|
||||
<button
|
||||
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
|
||||
class="btn btn-primary px-5 py-2.5"
|
||||
>
|
||||
@Icon("book-open", "h-4 w-4")
|
||||
<span>Read Now</span>
|
||||
</button>
|
||||
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
||||
<button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
|
||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4")<span>Mark Unread</span></span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
} else {
|
||||
<button @click="toggleRead(true)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
|
||||
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check", "h-4 w-4")<span>Mark Read</span></span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</button>
|
||||
<!-- Edit Metadata (placeholder) -->
|
||||
<button
|
||||
@click="showMetadataEditor()"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
✏️ Edit Metadata
|
||||
</button>
|
||||
</div>
|
||||
<!-- Rating Display -->
|
||||
<div class="mb-6" @mouseleave="ratingHover = 0">
|
||||
<span class="text-2xl">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span style="position: relative; display: inline-block;">
|
||||
<span :style="starFill(i)">★</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating(i*2-1)"
|
||||
@mouseenter="ratingHover = i*2-1"
|
||||
:disabled="ratingSaving"
|
||||
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
|
||||
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating(i*2)"
|
||||
@mouseenter="ratingHover = i*2"
|
||||
:disabled="ratingSaving"
|
||||
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
|
||||
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||
></button>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
|
||||
<button
|
||||
type="button"
|
||||
x-show="userRating > 0 && !ratingSaving"
|
||||
@click="clearRating()"
|
||||
class="ml-2 text-xs underline hover:opacity-70"
|
||||
style="color: var(--text-secondary);"
|
||||
>Clear</button>
|
||||
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Community Rating Display (from metadata) -->
|
||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
||||
<div class="mb-2">
|
||||
<span class="text-lg" style="color: var(--text-secondary);">
|
||||
Community Rating:
|
||||
<span class="font-bold" style="color: var(--text-primary);">
|
||||
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
||||
<button @click="showProgressSyncModal()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("sync", "h-4 w-4")
|
||||
<span>Sync Progress</span>
|
||||
</button>
|
||||
}
|
||||
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Notes</span>
|
||||
if book.NotesCount + book.HighlightsCount > 0 {
|
||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||
}
|
||||
</button>
|
||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||
@Icon("edit", "h-4 w-4")
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Rating -->
|
||||
<div class="mb-5" @mouseleave="ratingHover = 0">
|
||||
<span class="text-2xl">
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span style="position: relative; display: inline-block;">
|
||||
<span :style="starFill(i)">★</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating(i*2-1)"
|
||||
@mouseenter="ratingHover = i*2-1"
|
||||
:disabled="ratingSaving"
|
||||
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
|
||||
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating(i*2)"
|
||||
@mouseenter="ratingHover = i*2"
|
||||
:disabled="ratingSaving"
|
||||
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
|
||||
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||
></button>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
|
||||
<button
|
||||
type="button"
|
||||
x-show="userRating > 0 && !ratingSaving"
|
||||
@click="clearRating()"
|
||||
class="ml-2 text-xs underline hover:opacity-70"
|
||||
style="color: var(--text-secondary);"
|
||||
>Clear</button>
|
||||
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Community rating -->
|
||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
||||
<div class="mb-3 text-sm" style="color: var(--text-secondary);">
|
||||
Community:
|
||||
<span class="font-bold ml-1" style="color: var(--text-primary);">
|
||||
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
|
||||
</span>
|
||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
||||
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Series Badge -->
|
||||
if book.Series.Valid && book.Series.String != "" {
|
||||
<div class="mb-4">
|
||||
<a
|
||||
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||
class="px-3 py-1 rounded-full text-sm font-semibold inline-block hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--accent); color: white; text-decoration: none;"
|
||||
>
|
||||
{ book.Series.String }
|
||||
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
||||
#{ book.SeriesNumber.Int32 }
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
<!-- Reading Direction Badge (for manga/comics) -->
|
||||
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="px-3 py-1 rounded-full text-sm font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
📖 { strings.ToUpper(book.ReadingDirection.String) }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Comic-Specific Badges -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<!-- Age Rating Badge -->
|
||||
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
||||
>
|
||||
{ book.AgeRating.String }
|
||||
</span>
|
||||
<span class="ml-2">{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Black and White Badge -->
|
||||
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
||||
>
|
||||
B&W
|
||||
</span>
|
||||
}
|
||||
<!-- Story Arc Badge -->
|
||||
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
||||
>
|
||||
📚 { book.StoryArc.String }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
if len(book.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
for _, tag := range book.Tags {
|
||||
<!-- Badges: series, direction, tags -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
if book.Series.Valid && book.Series.String != "" {
|
||||
<a
|
||||
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
|
||||
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||
class="chip hover:opacity-90"
|
||||
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
|
||||
>
|
||||
{ tag }
|
||||
{ book.Series.String }
|
||||
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
||||
#{ book.SeriesNumber.Int32 }
|
||||
}
|
||||
</a>
|
||||
}
|
||||
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
||||
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
|
||||
}
|
||||
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
||||
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span>
|
||||
}
|
||||
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
||||
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span>
|
||||
}
|
||||
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
||||
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.StoryArc.String }</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Description/Synopsis -->
|
||||
if book.Description.Valid && book.Description.String != "" {
|
||||
<div class="mb-6">
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
|
||||
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(
|
||||
bluemonday.UGCPolicy().Sanitize(book.Description.String),
|
||||
).ToComponent()
|
||||
if len(book.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
for _, tag := range book.Tags {
|
||||
<a
|
||||
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||
class="badge hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
|
||||
>
|
||||
{ tag }
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Summary (from ComicInfo.xml, if different from description) -->
|
||||
</div>
|
||||
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
|
||||
<!-- Synopsis -->
|
||||
if book.Description.Valid && book.Description.String != "" {
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
|
||||
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
|
||||
<div class="mb-6">
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3>
|
||||
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(
|
||||
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
|
||||
).ToComponent()
|
||||
<div class="max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed" style="color: var(--text-secondary)">
|
||||
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Metadata Notes (technical notes from metadata files) -->
|
||||
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Metadata Notes</h3>
|
||||
<div class="text-sm" style="color: var(--text-secondary);">
|
||||
{ book.MetadataNotes.String }
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--text-secondary);">{ book.MetadataNotes.String }</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Progress Section -->
|
||||
<!-- Progress -->
|
||||
if book.ReadingProgress != nil {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
|
||||
if book.ActiveConflict != nil {
|
||||
<div
|
||||
class="mb-4 p-3 rounded-lg border"
|
||||
style="background-color: #f59e0b20; border-color: #f59e0b;"
|
||||
>
|
||||
<p style="color: var(--text-primary);">
|
||||
⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve
|
||||
</p>
|
||||
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border: 1px solid var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5 shrink-0")
|
||||
<p class="text-sm" style="color: var(--text-primary);">Progress conflict detected — click "Sync Progress" to review and resolve.</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Progress Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="w-full rounded-full h-3" style="background-color: var(--bg-primary);">
|
||||
<div class="w-full rounded-full h-2.5" style="background-color: var(--bg-primary);">
|
||||
<div
|
||||
class="h-3 rounded-full transition-all"
|
||||
class="h-2.5 rounded-full transition-all"
|
||||
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Progress</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--accent);">
|
||||
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
||||
</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Progress</p>
|
||||
<p class="text-2xl font-bold" style="color: var(--accent);">{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%</p>
|
||||
</div>
|
||||
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Page</p>
|
||||
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Page</p>
|
||||
<p style="color: var(--text-primary)">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.LastReadAt.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Last Read</p>
|
||||
<p>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Last Read</p>
|
||||
<p class="text-sm" style="color: var(--text-primary)">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ReadingProgress.LastSyncSource.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Source</p>
|
||||
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Source</p>
|
||||
<p class="capitalize" style="color: var(--text-primary)">{ book.ReadingProgress.LastSyncSource.String }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Metadata Grid -->
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<!-- Metadata grid -->
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Publication Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4 text-sm">
|
||||
if book.Publisher.Valid && book.Publisher.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Publisher</p>
|
||||
<p>{ book.Publisher.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
|
||||
<p style="color: var(--text-primary)">{ book.Publisher.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.DatePublished.Valid {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Published</p>
|
||||
<p>{ book.DatePublished.Time.Format("01-02-2006") }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Published</p>
|
||||
<p style="color: var(--text-primary)">{ book.DatePublished.Time.Format("01-02-2006") }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Isbn.Valid && book.Isbn.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Isbn</p>
|
||||
<p>{ book.Isbn.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">ISBN</p>
|
||||
<p style="color: var(--text-primary)">{ book.Isbn.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Language.Valid && book.Language.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Language</p>
|
||||
<p class="capitalize">{ book.Language.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
|
||||
<p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Edition.Valid && book.Edition.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Edition</p>
|
||||
<p>{ book.Edition.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
|
||||
<p style="color: var(--text-primary)">{ book.Edition.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Pages</p>
|
||||
<p>{ book.PageCount.Int32 }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
|
||||
<p style="color: var(--text-primary)">{ book.PageCount.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Genre.Valid && book.Genre.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Genre</p>
|
||||
<p>{ book.Genre.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
|
||||
<p style="color: var(--text-primary)">{ book.Genre.String }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Series Information (if not shown in badge) -->
|
||||
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Series Count</p>
|
||||
<p>{ book.SeriesCount.Int32 } items</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Series Count</p>
|
||||
<p style="color: var(--text-primary)">{ book.SeriesCount.Int32 } items</p>
|
||||
</div>
|
||||
}
|
||||
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Volume</p>
|
||||
<p>Vol. { book.Volume.Int32 }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Volume</p>
|
||||
<p style="color: var(--text-primary)">Vol. { book.Volume.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
if book.Imprint.Valid && book.Imprint.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Imprint</p>
|
||||
<p>{ book.Imprint.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
|
||||
<p style="color: var(--text-primary)">{ book.Imprint.String }</p>
|
||||
</div>
|
||||
}
|
||||
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Copyright Year</p>
|
||||
<p>{ book.CopyrightYear.Int32 }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Copyright</p>
|
||||
<p style="color: var(--text-primary)">{ book.CopyrightYear.Int32 }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Comic-Specific Fields -->
|
||||
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Manga Type</p>
|
||||
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Manga Type</p>
|
||||
<p class="capitalize" style="color: var(--text-primary)">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
||||
</div>
|
||||
}
|
||||
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Scan Info</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Scan Info</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
|
||||
</div>
|
||||
}
|
||||
if len(book.AlternateInfo) > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Alternate Series</p>
|
||||
<p class="text-sm">{ string(book.AlternateInfo) }</p>
|
||||
</div>
|
||||
}
|
||||
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Alternate Series</p>
|
||||
<p>{ altSeries }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Alternate Series</p>
|
||||
<p style="color: var(--text-primary)">{ altSeries }</p>
|
||||
</div>
|
||||
}
|
||||
if len(book.Contributors) > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Contributors</p>
|
||||
<p>{ strings.Join(book.Contributors, ", ") }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Contributors</p>
|
||||
<p style="color: var(--text-primary)">{ strings.Join(book.Contributors, ", ") }</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Technical Info -->
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">Format</p>
|
||||
<p>{ book.MimeType.String }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
|
||||
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
|
||||
</div>
|
||||
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
|
||||
<div>
|
||||
<p style="color: var(--text-secondary)">File Size</p>
|
||||
<p>{ formatFileSize(book.FileSize.Int64) }</p>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
|
||||
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- External IDs Section -->
|
||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||
<div class="mt-4 pt-4 border-t" style="border-color: var(--border);">
|
||||
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
📚 Goodreads
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
📚 Goodreads
|
||||
</a>
|
||||
}
|
||||
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
📖 Open Library
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
📖 Open Library
|
||||
</a>
|
||||
}
|
||||
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🔍 Google Books
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🔍 Google Books
|
||||
</a>
|
||||
}
|
||||
if book.Asin.Valid && book.Asin.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🛒 Amazon
|
||||
</a>
|
||||
} else if book.Isbn.Valid && book.Isbn.String != "" {
|
||||
<a
|
||||
href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🛒 Amazon
|
||||
</a>
|
||||
}
|
||||
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
||||
<a
|
||||
href={ book.WebUrl.String }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm hover:underline flex items-center gap-1"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
🔗 { getDomainName(book.WebUrl.String) }
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
|
||||
<a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
|
||||
} else {
|
||||
<a href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
|
||||
}
|
||||
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
|
||||
<a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
|
||||
} else {
|
||||
<a href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
|
||||
}
|
||||
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
|
||||
<a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
|
||||
} else {
|
||||
<a href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
|
||||
}
|
||||
if book.Asin.Valid && book.Asin.String != "" {
|
||||
<a href={ getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
|
||||
} else if book.Isbn.Valid && book.Isbn.String != "" {
|
||||
<a href={ getExternalURL("amazon", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
|
||||
}
|
||||
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
||||
<a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">{ getDomainName(book.WebUrl.String) }</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Collections Section -->
|
||||
<!-- Collections -->
|
||||
if len(book.Collections) > 0 {
|
||||
<div
|
||||
class="card p-6 rounded-lg border mb-6"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
for _, col := range book.Collections {
|
||||
<a
|
||||
href={ "/collections/" + uuidToString(col.ID) }
|
||||
class="px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
style="border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border hover:opacity-90 transition-opacity"
|
||||
style={ "border-color: " + col.Color.String + "; background-color: var(--bg-primary); text-decoration: none;" }
|
||||
>
|
||||
<span style="color: { col.Color.String };">{ col.Icon.String }</span>
|
||||
<span style={ "color: " + col.Color.String }>{ col.Icon.String }</span>
|
||||
<span style="color: var(--text-primary);">{ col.Name }</span>
|
||||
</a>
|
||||
}
|
||||
@@ -587,7 +424,6 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Modals -->
|
||||
@ProgressSyncModal(user, book)
|
||||
@NotesHighlightsModal(book)
|
||||
@MetadataEditorModal(book)
|
||||
|
||||
+182
-230
@@ -8,50 +8,52 @@ import (
|
||||
templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
<div
|
||||
id="progress-sync-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div
|
||||
class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
class="card w-full max-w-4xl my-8 p-6"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("sync", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="hideProgressSyncModal()"
|
||||
class="p-2 hover:opacity-80 rounded-lg"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
if book.ActiveConflict != nil {
|
||||
<div
|
||||
class="mb-6 p-4 rounded-lg border"
|
||||
style="background-color: #f59e0b20; border-color: #f59e0b;"
|
||||
class="mb-6 p-4 rounded-xl flex items-start gap-3"
|
||||
style="background-color: color-mix(in srgb, var(--status-warning) 15%, transparent); border: 1px solid var(--status-warning);"
|
||||
>
|
||||
<p class="font-semibold mb-2" style="color: var(--text-primary);">
|
||||
⚠️ Conflict Detected
|
||||
</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">
|
||||
Progress differs between devices. Choose which version to keep.
|
||||
</p>
|
||||
<span class="shrink-0 mt-0.5" style="color: var(--status-warning);">@Icon("alert", "h-5 w-5")</span>
|
||||
<div>
|
||||
<p class="font-semibold mb-1" style="color: var(--text-primary);">
|
||||
Conflict Detected
|
||||
</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">
|
||||
Progress differs between devices. Choose which version to keep.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
for source, data := range book.ActiveConflict.ConflictData {
|
||||
<div
|
||||
class="card p-4 rounded-lg border"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-4">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<span
|
||||
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
<span class="badge mb-2" style="background-color: var(--accent); color: #fff;">
|
||||
{ data.Source }
|
||||
</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">
|
||||
@@ -83,8 +85,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
<div class="mt-3">
|
||||
<button
|
||||
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Keep This
|
||||
</button>
|
||||
@@ -96,10 +97,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
<div class="mb-4" style="color: var(--text-secondary);">
|
||||
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
|
||||
</div>
|
||||
<div
|
||||
class="card p-6 rounded-lg border text-center"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
>
|
||||
<div class="card p-6 text-center">
|
||||
<div class="text-5xl font-bold mb-4" style="color: var(--accent);">
|
||||
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
||||
</div>
|
||||
@@ -121,8 +119,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
@click="hideProgressSyncModal()"
|
||||
class="px-4 py-2 rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -134,14 +131,16 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
templ NotesHighlightsModal(book handlers.MediaDetail) {
|
||||
<div
|
||||
id="notes-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div
|
||||
class="card rounded-lg p-8 w-full max-w-2xl mx-4 text-center"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
class="card w-full max-w-2xl p-8 text-center"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
>
|
||||
<div class="text-6xl mb-4">📝</div>
|
||||
<span class="inline-grid place-items-center h-14 w-14 rounded-2xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("edit", "h-7 w-7")
|
||||
</span>
|
||||
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2>
|
||||
<p class="mb-2" style="color: var(--text-secondary);">
|
||||
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
|
||||
@@ -150,8 +149,7 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
|
||||
<div>
|
||||
<button
|
||||
@click="hideNotesModal()"
|
||||
class="px-6 py-2 rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -163,27 +161,31 @@ templ NotesHighlightsModal(book handlers.MediaDetail) {
|
||||
templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<div
|
||||
id="metadata-editor-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg w-full max-w-5xl mx-4 my-8 flex flex-col max-h-[90vh]"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
|
||||
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
>
|
||||
<div class="flex justify-between items-center p-6 border-b flex-shrink-0" style="border-color: var(--border);">
|
||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Edit Metadata</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("edit", "h-5 w-5")
|
||||
</span>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Metadata</h2>
|
||||
</div>
|
||||
<button
|
||||
@click="hideMetadataEditor()"
|
||||
class="p-2 hover:opacity-80 rounded-lg"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-8 p-6 flex-1 min-h-0 overflow-y-auto">
|
||||
<!-- Cover Section (Left) -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-64 h-96 rounded-lg overflow-hidden shadow-lg mb-3 cursor-pointer relative group"
|
||||
<div class="w-64 h-96 rounded-xl overflow-hidden mb-3 cursor-pointer relative group"
|
||||
style="background-color: var(--bg-primary);"
|
||||
@click="document.getElementById('cover-upload-input').click()"
|
||||
>
|
||||
@@ -209,27 +211,26 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<div class="w-64 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary w-full"
|
||||
@click="generateCover()"
|
||||
x-show="coverGenerating"
|
||||
disabled
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Generating...
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary w-full"
|
||||
@click="generateCover()"
|
||||
x-show="!coverGenerating"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Generate Cover
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
|
||||
class="btn btn-ghost w-full"
|
||||
@click="removeCover()"
|
||||
x-show="hasExistingCover || newCoverPreview"
|
||||
>
|
||||
@@ -237,53 +238,43 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Accordion Fields (Right) -->
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<!-- Basic Info -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('basic')">
|
||||
<span class="font-semibold">Basic Info</span>
|
||||
<span x-text="openSections.basic ? '▾' : '▸'">▸</span>
|
||||
<span class="inline-flex">
|
||||
<span x-show="openSections.basic">@Icon("chevron-down", "h-4 w-4")</span>
|
||||
<span x-show="!openSections.basic" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="openSections.basic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||
<div x-show="openSections.basic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Title</label>
|
||||
<input type="text" name="title" value={ book.Title }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label>
|
||||
<input type="text" name="title" value={ book.Title } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Author</label>
|
||||
<input type="text" name="author" value={ textToString(book.Author) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label>
|
||||
<input type="text" name="author" value={ textToString(book.Author) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Description</label>
|
||||
<textarea name="description" rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
|
||||
<textarea name="description" rows="3" class="input"
|
||||
>{ textToString(book.Description) }</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Summary</label>
|
||||
<textarea name="summary" rows="2"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Summary</label>
|
||||
<textarea name="summary" rows="2" class="input"
|
||||
>{ textToString(book.Summary) }</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Tags</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Tags</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||
<template x-for="(tag, idx) in editorTags" :key="idx">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
<span class="chip">
|
||||
<span x-text="tag"></span>
|
||||
<button type="button" class="hover:opacity-70 leading-none" @click="removeEditorTag(idx)">✕</button>
|
||||
<button type="button" class="hover:bg-surface-hover rounded leading-none" @click="removeEditorTag(idx)">@Icon("close", "h-3 w-3")</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -297,20 +288,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
@keydown="onTagKeydown($event)"
|
||||
@blur="hideEditorTagDropdown()"
|
||||
placeholder="Add tag..."
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input" />
|
||||
<div
|
||||
x-show="showTagDropdown"
|
||||
x-transition
|
||||
class="mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
x-cloak
|
||||
class="mt-1 w-full rounded-lg border max-h-48 overflow-y-auto"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);"
|
||||
>
|
||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
||||
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
||||
:style="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover"
|
||||
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''"
|
||||
@click="selectEditorTagSuggestion(sug.value)"
|
||||
>
|
||||
<span x-text="sug.value"></span>
|
||||
@@ -321,164 +311,142 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label>
|
||||
<input type="number" name="community_rating" min="0" max="10" step="0.1"
|
||||
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Publication -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('publication')">
|
||||
<span class="font-semibold">Publication</span>
|
||||
<span x-text="openSections.publication ? '▾' : '▸'">▸</span>
|
||||
<span class="inline-flex">
|
||||
<span x-show="openSections.publication">@Icon("chevron-down", "h-4 w-4")</span>
|
||||
<span x-show="!openSections.publication" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="openSections.publication" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Publisher</label>
|
||||
<input type="text" name="publisher" value={ textToString(book.Publisher) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label>
|
||||
<input type="text" name="publisher" value={ textToString(book.Publisher) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Date Published</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label>
|
||||
<input type="date" name="date_published"
|
||||
value={ formatDateForInput(book.DatePublished) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Edition</label>
|
||||
<input type="text" name="edition" value={ textToString(book.Edition) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Edition</label>
|
||||
<input type="text" name="edition" value={ textToString(book.Edition) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Language</label>
|
||||
<input type="text" name="language" value={ textToString(book.Language) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Language</label>
|
||||
<input type="text" name="language" value={ textToString(book.Language) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Genre</label>
|
||||
<input type="text" name="genre" value={ textToString(book.Genre) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Genre</label>
|
||||
<input type="text" name="genre" value={ textToString(book.Genre) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Copyright Year</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Copyright Year</label>
|
||||
<input type="number" name="copyright_year"
|
||||
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Series -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('series')">
|
||||
<span class="font-semibold">Series</span>
|
||||
<span x-text="openSections.series ? '▾' : '▸'">▸</span>
|
||||
<span class="inline-flex">
|
||||
<span x-show="openSections.series">@Icon("chevron-down", "h-4 w-4")</span>
|
||||
<span x-show="!openSections.series" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="openSections.series" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||
<div x-show="openSections.series" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Series</label>
|
||||
<input type="text" name="series" value={ textToString(book.Series) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Series</label>
|
||||
<input type="text" name="series" value={ textToString(book.Series) } class="input" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Number</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
|
||||
<input type="number" name="series_number"
|
||||
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Count</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Count</label>
|
||||
<input type="number" name="series_count"
|
||||
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Volume</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Volume</label>
|
||||
<input type="number" name="volume"
|
||||
value={ fmt.Sprintf("%d", book.Volume.Int32) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Identifiers -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('identifiers')">
|
||||
<span class="font-semibold">Identifiers</span>
|
||||
<span x-text="openSections.identifiers ? '▾' : '▸'">▸</span>
|
||||
<span class="inline-flex">
|
||||
<span x-show="openSections.identifiers">@Icon("chevron-down", "h-4 w-4")</span>
|
||||
<span x-show="!openSections.identifiers" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">ISBN</label>
|
||||
<input type="text" name="isbn" value={ textToString(book.Isbn) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ISBN</label>
|
||||
<input type="text" name="isbn" value={ textToString(book.Isbn) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">ASIN</label>
|
||||
<input type="text" name="asin" value={ textToString(book.Asin) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ASIN</label>
|
||||
<input type="text" name="asin" value={ textToString(book.Asin) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Goodreads ID</label>
|
||||
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Goodreads ID</label>
|
||||
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label>
|
||||
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label>
|
||||
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Google Books ID</label>
|
||||
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Google Books ID</label>
|
||||
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Web URL</label>
|
||||
<input type="url" name="web_url" value={ textToString(book.WebUrl) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Web URL</label>
|
||||
<input type="url" name="web_url" value={ textToString(book.WebUrl) } class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Comic/Manga -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('comic')">
|
||||
<span class="font-semibold">Comic/Manga</span>
|
||||
<span x-text="openSections.comic ? '▾' : '▸'">▸</span>
|
||||
<span class="inline-flex">
|
||||
<span x-show="openSections.comic">@Icon("chevron-down", "h-4 w-4")</span>
|
||||
<span x-show="!openSections.comic" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="openSections.comic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||
<div x-show="openSections.comic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||
<div>
|
||||
<label for="manga_type" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
||||
<select id="manga_type" name="manga_type"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
|
||||
<label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
||||
<select id="manga_type" name="manga_type" class="input">
|
||||
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
||||
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
|
||||
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
|
||||
@@ -486,10 +454,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reading_direction" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
||||
<select id="reading_direction" name="reading_direction"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
|
||||
<label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
||||
<select id="reading_direction" name="reading_direction" class="input">
|
||||
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
|
||||
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
|
||||
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
|
||||
@@ -497,34 +463,24 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Age Rating</label>
|
||||
<input type="text" name="age_rating" value={ textToString(book.AgeRating) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Age Rating</label>
|
||||
<input type="text" name="age_rating" value={ textToString(book.AgeRating) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Story Arc</label>
|
||||
<input type="text" name="story_arc" value={ textToString(book.StoryArc) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Story Arc</label>
|
||||
<input type="text" name="story_arc" value={ textToString(book.StoryArc) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Imprint</label>
|
||||
<input type="text" name="imprint" value={ textToString(book.Imprint) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Imprint</label>
|
||||
<input type="text" name="imprint" value={ textToString(book.Imprint) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Scan Information</label>
|
||||
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Scan Information</label>
|
||||
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
|
||||
<textarea name="metadata_notes" rows="2"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
|
||||
<textarea name="metadata_notes" rows="2" class="input"
|
||||
>{ textToString(book.MetadataNotes) }</textarea>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -535,40 +491,37 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Technical -->
|
||||
<div class="border rounded-lg" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
||||
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
@click="toggleSection('technical')">
|
||||
<span class="font-semibold">Technical</span>
|
||||
<span x-text="openSections.technical ? '▾' : '▸'">▸</span>
|
||||
<span class="inline-flex">
|
||||
<span x-show="openSections.technical">@Icon("chevron-down", "h-4 w-4")</span>
|
||||
<span x-show="!openSections.technical" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="openSections.technical" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||
<div x-show="openSections.technical" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Page Count</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Page Count</label>
|
||||
<input type="number" name="page_count"
|
||||
value={ fmt.Sprintf("%d", book.PageCount.Int32) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
|
||||
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) }
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
|
||||
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Format</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label>
|
||||
<input type="text" value={ textToString(book.MimeType) } readonly
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input opacity-60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">File Size</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label>
|
||||
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm opacity-60"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);" />
|
||||
class="input opacity-60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,16 +531,15 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||
<button
|
||||
@click="hideMetadataEditor()"
|
||||
class="px-4 py-2 rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveMetadata()"
|
||||
class="px-6 py-2 rounded-lg font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+620
-553
File diff suppressed because it is too large
Load Diff
+251
-348
@@ -3,6 +3,7 @@ package templates
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
templ BookShelf(
|
||||
@@ -11,10 +12,10 @@ templ BookShelf(
|
||||
currentLibraryID string,
|
||||
errorMessage string,
|
||||
savedFilters []database.SavedFilters,
|
||||
books []handlers.BookInfo, // NEW
|
||||
limit int, // NEW
|
||||
offset int, // NEW
|
||||
count int, // NEW
|
||||
books []handlers.BookInfo,
|
||||
limit int,
|
||||
offset int,
|
||||
count int,
|
||||
) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -24,6 +25,7 @@ templ BookShelf(
|
||||
<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 }"
|
||||
@@ -31,363 +33,287 @@ templ BookShelf(
|
||||
x-init="initBookshelf()"
|
||||
>
|
||||
@Header(user, "/bookshelf")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Filter Bar -->
|
||||
<div
|
||||
class="mb-6 card p-4 rounded-lg border"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<!-- Library Selector -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Library
|
||||
</label>
|
||||
<select
|
||||
id="library-select"
|
||||
name="library_id"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
>
|
||||
if len(libraries) == 0 {
|
||||
<option value="">No libraries available</option>
|
||||
} else {
|
||||
if currentLibraryID == "" {
|
||||
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
|
||||
} else {
|
||||
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
}
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-6">
|
||||
<form id="filter-form" hx-get="/api/media-items/search" hx-target="#books-grid">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
name="q"
|
||||
type="text"
|
||||
class="input pl-10"
|
||||
placeholder="Search all fields…"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
name="sort"
|
||||
class="input w-auto cursor-pointer"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#filter-form"
|
||||
>
|
||||
<option value="title ASC">Title (A-Z)</option>
|
||||
<option value="title DESC">Title (Z-A)</option>
|
||||
<option value="author ASC">Author (A-Z)</option>
|
||||
<option value="created_at DESC">Date Added</option>
|
||||
<option value="page_count DESC">Page Count</option>
|
||||
</select>
|
||||
<button type="button" @click="filtersOpen = true" class="btn btn-secondary">
|
||||
@Icon("filter", "h-4 w-4")
|
||||
<span>Filters</span>
|
||||
</button>
|
||||
<button type="button" @click="showSaveFilterModal()" class="btn btn-secondary">
|
||||
@Icon("save", "h-4 w-4")
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button type="button" @click="toggleFiltersDropdown()" data-load-filter-btn class="btn btn-secondary">
|
||||
@Icon("folder", "h-4 w-4")
|
||||
<span>Load</span>
|
||||
</button>
|
||||
<div
|
||||
x-show="showFiltersDropdown"
|
||||
@click.outside="showFiltersDropdown = false"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute top-full right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl shadow-lg z-50 card p-4"
|
||||
x-cloak
|
||||
>
|
||||
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Saved Filters</h3>
|
||||
<div class="space-y-2" id="saved-filters-list">
|
||||
for _, filter := range savedFilters {
|
||||
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-hover" data-filter-id={ uuidToString(filter.ID) }>
|
||||
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded text-sm" style="color: var(--text-primary);">
|
||||
{ filter.Name }
|
||||
</button>
|
||||
<button data-action="delete-filter" @click="deleteFilter($event)" class="icon-btn h-8 w-8" title="Delete filter">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if len(savedFilters) == 0 {
|
||||
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
|
||||
No saved filters yet
|
||||
</div>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Search Input -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
name="q"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="Search all fields..."
|
||||
/>
|
||||
</div>
|
||||
<button type="button" @click="clearFilters()" class="btn btn-ghost">
|
||||
@Icon("close", "h-4 w-4")
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Filter drawer -->
|
||||
<div
|
||||
x-show="filtersOpen"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[65]"
|
||||
@keydown.escape.window="filtersOpen = false"
|
||||
>
|
||||
<div class="absolute inset-0" style="background-color: var(--surface-overlay);" @click="filtersOpen = false"></div>
|
||||
<div
|
||||
class="absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto"
|
||||
style="background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="translate-x-full"
|
||||
>
|
||||
<div class="sticky top-0 flex items-center justify-between px-6 py-4 border-b" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Filters</h2>
|
||||
<button type="button" @click="filtersOpen = false" class="icon-btn" aria-label="Close filters">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<!-- Author Filter with Autocomplete -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="author_filter"
|
||||
placeholder="Filter by author"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
list="author-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||
/>
|
||||
<datalist id="author-datalist"></datalist>
|
||||
</div>
|
||||
<!-- Tags Filter with Autocomplete -->
|
||||
<div class="flex-1 min-w-[150px] relative">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tags_filter"
|
||||
placeholder="Filter by tags"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
||||
@keydown="onTagFilterKeydown($event)"
|
||||
@blur="hideTagDropdown()"
|
||||
/>
|
||||
<div
|
||||
x-show="showTagDropdown"
|
||||
x-transition
|
||||
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
>
|
||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
||||
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
||||
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
||||
@click="selectTagSuggestion(sug.value)"
|
||||
>
|
||||
<span x-text="sug.value"></span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
||||
</button>
|
||||
<div class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library</label>
|
||||
<select id="library-select" name="library_id" class="input">
|
||||
if len(libraries) == 0 {
|
||||
<option value="">No libraries available</option>
|
||||
} else {
|
||||
if currentLibraryID == "" {
|
||||
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
|
||||
} else {
|
||||
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
}
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
name="author_filter"
|
||||
placeholder="Filter by author"
|
||||
class="input"
|
||||
list="author-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||
/>
|
||||
<datalist id="author-datalist"></datalist>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tags_filter"
|
||||
placeholder="Filter by tags"
|
||||
class="input"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
||||
@keydown="onTagFilterKeydown($event)"
|
||||
@blur="hideTagDropdown()"
|
||||
/>
|
||||
<div
|
||||
x-show="showTagDropdown"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border);"
|
||||
>
|
||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
||||
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
||||
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
||||
@click="selectTagSuggestion(sug.value)"
|
||||
>
|
||||
<span x-text="sug.value"></span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Series</label>
|
||||
<input
|
||||
type="text"
|
||||
name="series_filter"
|
||||
placeholder="Filter by series"
|
||||
class="input"
|
||||
list="series-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
||||
/>
|
||||
<datalist id="series-datalist"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label>
|
||||
<input
|
||||
type="text"
|
||||
name="language_filter"
|
||||
placeholder="Filter by language"
|
||||
class="input"
|
||||
list="language-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
||||
/>
|
||||
<datalist id="language-datalist"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Year Range</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" name="year_min" placeholder="From" class="input"/>
|
||||
<input type="number" name="year_max" placeholder="To" class="input"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Cover</label>
|
||||
<input
|
||||
type="button"
|
||||
id="has-cover-tristate"
|
||||
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
|
||||
:class="{
|
||||
'state-null': hasCoverState === null,
|
||||
'state-true': hasCoverState === true,
|
||||
'state-false': hasCoverState === false
|
||||
}"
|
||||
:value="hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
|
||||
@click="cycleHasCover()"
|
||||
/>
|
||||
<template x-if="hasCoverState !== null">
|
||||
<input
|
||||
type="hidden"
|
||||
name="has_cover"
|
||||
:value="hasCoverState ? 'true' : 'false'"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
// <!-- Genre Filter with Autocomplete -->
|
||||
// <div class="flex-1 min-w-[150px]">
|
||||
// <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
// Genre
|
||||
// </label>
|
||||
// <input
|
||||
// type="text"
|
||||
// name="genre_filter"
|
||||
// placeholder="Filter by genre"
|
||||
// class="w-full px-3 py-2 border rounded-lg"
|
||||
// style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
// list="genre-datalist"
|
||||
// @input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
|
||||
// />
|
||||
// <datalist id="genre-datalist"></datalist>
|
||||
// </div>
|
||||
<!-- Series Filter with Autocomplete (NEW) -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Series
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="series_filter"
|
||||
placeholder="Filter by series"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
list="series-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
||||
/>
|
||||
<datalist id="series-datalist"></datalist>
|
||||
</div>
|
||||
<!-- Language Filter with Autocomplete (NEW) -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Language
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="language_filter"
|
||||
placeholder="Filter by language"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
list="language-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
||||
/>
|
||||
<datalist id="language-datalist"></datalist>
|
||||
</div>
|
||||
<!-- Year Range -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Year Range
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="year_min"
|
||||
placeholder="From"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
name="year_max"
|
||||
placeholder="To"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Has Cover Filter - TriState Button -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Cover Filter
|
||||
</label>
|
||||
<input
|
||||
type="button"
|
||||
id="has-cover-tristate"
|
||||
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
|
||||
:class="{
|
||||
'state-null': hasCoverState === null,
|
||||
'state-true': hasCoverState === true,
|
||||
'state-false': hasCoverState === false
|
||||
}"
|
||||
:value="hasCoverState === null ? '○ Cover: Any' : hasCoverState === true ? '✓ Has Cover' : '✗ No Cover'"
|
||||
@click="cycleHasCover()"
|
||||
/>
|
||||
<!-- Hidden input for HTMX form submission - conditionally rendered -->
|
||||
<template x-if="hasCoverState !== null">
|
||||
<input
|
||||
type="hidden"
|
||||
name="has_cover"
|
||||
:value="hasCoverState ? 'true' : 'false'"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Sort By Dropdown (PRESERVED) -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Sort By
|
||||
</label>
|
||||
<select
|
||||
name="sort"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#filter-form"
|
||||
>
|
||||
<option value="title ASC">Title (A-Z)</option>
|
||||
<option value="title DESC">Title (Z-A)</option>
|
||||
<option value="author ASC">Author (A-Z)</option>
|
||||
<option value="created_at DESC">Date Added</option>
|
||||
<option value="page_count DESC">Page Count</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
>
|
||||
🔍 Search
|
||||
</button>
|
||||
</div>
|
||||
<!-- Save Filter Button (PRESERVED) -->
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="showSaveFilterModal()"
|
||||
class="px-4 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
>
|
||||
💾 Save Filter
|
||||
</button>
|
||||
</div>
|
||||
<!-- Load Filter Button (PRESERVED) -->
|
||||
<div class="flex items-end relative">
|
||||
<button
|
||||
@click="toggleFiltersDropdown()"
|
||||
data-load-filter-btn
|
||||
class="px-4 py-2 rounded-lg font-medium border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
📂 Load Filter
|
||||
</button>
|
||||
<!-- Saved Filters Dropdown (PRESERVED) -->
|
||||
<div
|
||||
x-show="showFiltersDropdown"
|
||||
@click.outside="showFiltersDropdown = false"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute top-full mt-2 w-80 rounded-lg shadow-lg z-50"
|
||||
:class="dropdownAlign === 'left' ? 'left-0' : 'right-0'"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
||||
>
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">
|
||||
Saved Filters
|
||||
</h3>
|
||||
<!-- Filter List -->
|
||||
<div class="space-y-2" id="saved-filters-list">
|
||||
for _, filter := range savedFilters {
|
||||
<div class="flex items-center justify-between p-2 rounded hover:opacity-80" style="background-color: var(--bg-primary);" data-filter-id={ uuidToString(filter.ID) }>
|
||||
<button data-action="load-filter" @click="loadFilter($event)" class="flex-1 text-left px-2 py-1 rounded" style="color: var(--text-primary);">
|
||||
{ filter.Name }
|
||||
</button>
|
||||
<button data-action="delete-filter" @click="deleteFilter($event)" class="p-1 hover:opacity-70 rounded" style="color: var(--text-secondary);" title="Delete filter">🗑️</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
if len(savedFilters) == 0 {
|
||||
<div class="text-sm py-4 text-center" style="color: var(--text-secondary);">
|
||||
No saved filters yet
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Clear Filters Button (PRESERVED) -->
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="px-4 py-2 rounded-lg font-medium border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
✕ Clear
|
||||
</button>
|
||||
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||
<button type="submit" @click="filtersOpen = false" class="btn btn-primary w-full">Apply Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Books Grid -->
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
</div>
|
||||
</form>
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||
<!-- Error Message -->
|
||||
if errorMessage != "" {
|
||||
<div class="mt-6 p-4 rounded-lg border bg-red-500/10 border-red-500">
|
||||
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
|
||||
<p style="color: var(--text-primary)">{ errorMessage }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="mt-6 flex justify-center gap-2">
|
||||
<div id="pagination" class="mt-6 flex justify-center items-center gap-2">
|
||||
if count > 0 {
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
|
||||
class="btn btn-secondary disabled:opacity-40"
|
||||
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit) }
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
disabled?={ offset <= 0 }
|
||||
>
|
||||
← Previous
|
||||
@Icon("chevron-left", "h-4 w-4")
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
<span class="px-4 py-2" style="color: var(--text-secondary);">
|
||||
<span class="px-4 py-2 text-sm" style="color: var(--text-secondary);">
|
||||
Page { offset / limit + 1 }
|
||||
</span>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg border disabled:opacity-50"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset + limit }"
|
||||
type="button"
|
||||
class="btn btn-secondary disabled:opacity-40"
|
||||
hx-get={ fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit) }
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
disabled?={ offset+limit >= count }
|
||||
>
|
||||
Next →
|
||||
<span>Next</span>
|
||||
@Icon("chevron-right", "h-4 w-4")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Save Filter Modal -->
|
||||
<div
|
||||
x-show="showSaveModal"
|
||||
@click.self="showSaveModal = false"
|
||||
x-transition
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background-color: rgba(0, 0, 0, 0.7); display: none;"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[70] flex items-center justify-center p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div
|
||||
@click.stop
|
||||
class="card rounded-lg p-6 w-full max-w-md mx-4"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div @click.stop class="card rounded-2xl p-6 w-full max-w-md">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Save Filter</h2>
|
||||
<button
|
||||
@click="showSaveModal = false"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-primary)"
|
||||
>✕</button>
|
||||
<button @click="showSaveModal = false" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
hx-post="/api/saved-filters"
|
||||
@@ -397,38 +323,15 @@ templ BookShelf(
|
||||
@htmx:afterRequest="if(event.detail.xhr.status < 400) { afterFilterSave() }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Filter Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="filter_name"
|
||||
placeholder="My Custom Filter"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
required
|
||||
/>
|
||||
<div id="filter-save-error" style="display: none; color: #ef4444; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;"></div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label>
|
||||
<input type="text" name="filter_name" placeholder="My Custom Filter" class="input" required/>
|
||||
<div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></div>
|
||||
<input type="hidden" name="resource_type" value="media-items"/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="showSaveModal = false"
|
||||
class="px-4 py-2 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
<!-- Hidden pagination state -->
|
||||
<input type="hidden" name="limit" value="50"/>
|
||||
<input type="hidden" name="offset" value="0"/>
|
||||
</form>
|
||||
|
||||
+243
-140
File diff suppressed because one or more lines are too long
@@ -13,23 +13,25 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ pageTitle } - 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 }">
|
||||
@Header(user, backUrl)
|
||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||
<div class="w-full px-4 py-3 flex items-center gap-4">
|
||||
<a href={ backUrl } class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
← { backLabel }
|
||||
<div class="app-subbar border-b" style="background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-3">
|
||||
<a href={ backUrl } class="btn btn-ghost">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
<span>{ backLabel }</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full px-4 py-8">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold" style="background-color: var(--accent); color: white;">
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">
|
||||
{ badgeIcon } { badgeLabel }
|
||||
</span>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ title }</h1>
|
||||
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d", len(books)) } books</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ title }</h1>
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ fmt.Sprintf("%d", len(books)) } books</span>
|
||||
</div>
|
||||
if len(books) > 0 {
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
@@ -38,10 +40,10 @@ templ BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">{ emptyIcon }</div>
|
||||
<div class="card text-center py-16 px-6">
|
||||
<div class="text-5xl mb-4">{ emptyIcon }</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Books Found</h3>
|
||||
<p>{ emptyMessage }</p>
|
||||
<p style="color: var(--text-secondary)">{ emptyMessage }</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -55,90 +55,98 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center gap-4\"><a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"app-subbar border-b\" style=\"background-color: color-mix(in srgb, var(--bg-secondary) 82%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-3\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 templ.SafeURL
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 21, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"text-sm hover:opacity-80 transition-opacity\" style=\"color: var(--text-secondary); text-decoration: none;\">← ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"btn btn-ghost\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</a></div></div><div class=\"w-full px-4 py-8\"><div class=\"flex items-center gap-4 mb-6\"><span class=\"px-3 py-1 rounded-full text-sm font-semibold\" style=\"background-color: var(--accent); color: white;\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></a></div></div><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"flex flex-wrap items-center gap-3 mb-6\"><span class=\"chip\" style=\"background-color: var(--accent); color: var(--bg-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 29, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</h1><span class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</h1><span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 32, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " books</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " books</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(books) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -148,43 +156,43 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"card text-center py-16 px-6\"><div class=\"text-5xl mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 42, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Found</h3><p style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 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, 14, "</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -192,7 +200,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
package templates
|
||||
|
||||
templ CollectionModal(collection CollectionData) {
|
||||
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
if collection.ID != "" {
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Collection</h2>
|
||||
} else {
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
|
||||
}
|
||||
<button type="button" @click="closeCollectionModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("folder", "h-5 w-5")
|
||||
</span>
|
||||
if collection.ID != "" {
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Collection</h2>
|
||||
} else {
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Create Collection</h2>
|
||||
}
|
||||
</div>
|
||||
<button type="button" @click="closeCollectionModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
if collection.ID != "" {
|
||||
<form
|
||||
@@ -18,65 +25,57 @@ templ CollectionModal(collection CollectionData) {
|
||||
>
|
||||
<input type="hidden" name="id" value={ collection.ID }/>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={ collection.Name }
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="My Reading List"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
>{ collection.Description }</textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon</label>
|
||||
<!-- Search/Text Input -->
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Icon</label>
|
||||
<input
|
||||
type="text"
|
||||
id="icon-search"
|
||||
class="w-full px-4 py-2 border rounded-lg mb-2"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="🔍 Search or type emoji..."
|
||||
class="input mb-2"
|
||||
placeholder="Search or type emoji..."
|
||||
maxlength="4"
|
||||
oninput="filterIcons(this.value)"
|
||||
onfocus="showAllIcons()"
|
||||
@input="filterIcons($event.target.value)"
|
||||
@focus="showAllIcons()"
|
||||
/>
|
||||
<!-- Hidden input for form submission -->
|
||||
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
|
||||
<!-- Emoji Grid -->
|
||||
<div
|
||||
id="icon-grid"
|
||||
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg"
|
||||
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
>
|
||||
<!-- Populated dynamically by JavaScript -->
|
||||
</div>
|
||||
></div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Color</label>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
|
||||
<button type="button" @click="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
|
||||
<button type="button" @click="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
|
||||
<button type="button" @click="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
|
||||
<button type="button" @click="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
|
||||
<button type="button" @click="selectColor('blue')" data-color="blue" class="w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400" aria-label="Blue"></button>
|
||||
<button type="button" @click="selectColor('red')" data-color="red" class="w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400" aria-label="Red"></button>
|
||||
<button type="button" @click="selectColor('yellow')" data-color="yellow" class="w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400" aria-label="Yellow"></button>
|
||||
<button type="button" @click="selectColor('green')" data-color="green" class="w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400" aria-label="Green"></button>
|
||||
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
|
||||
</div>
|
||||
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Update Collection</button>
|
||||
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update Collection</button>
|
||||
</div>
|
||||
</form>
|
||||
} else {
|
||||
@@ -85,64 +84,56 @@ templ CollectionModal(collection CollectionData) {
|
||||
hx-redirect="/collections"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="My Reading List"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon</label>
|
||||
<!-- Search/Text Input -->
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Icon</label>
|
||||
<input
|
||||
type="text"
|
||||
id="icon-search"
|
||||
class="w-full px-4 py-2 border rounded-lg mb-2"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="🔍 Search or type emoji..."
|
||||
class="input mb-2"
|
||||
placeholder="Search or type emoji..."
|
||||
maxlength="4"
|
||||
oninput="filterIcons(this.value)"
|
||||
onfocus="showAllIcons()"
|
||||
@input="filterIcons($event.target.value)"
|
||||
@focus="showAllIcons()"
|
||||
/>
|
||||
<!-- Hidden input for form submission -->
|
||||
<input type="hidden" name="icon" id="collection-icon" value={ collection.Icon }/>
|
||||
<!-- Emoji Grid -->
|
||||
<div
|
||||
id="icon-grid"
|
||||
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg"
|
||||
class="grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
>
|
||||
<!-- Populated dynamically by JavaScript -->
|
||||
</div>
|
||||
></div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Color</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Color</label>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="selectColor('blue')" class="w-8 h-8 rounded-full color-option bg-blue-500"></button>
|
||||
<button type="button" @click="selectColor('red')" class="w-8 h-8 rounded-full color-option bg-red-500"></button>
|
||||
<button type="button" @click="selectColor('yellow')" class="w-8 h-8 rounded-full color-option bg-yellow-500"></button>
|
||||
<button type="button" @click="selectColor('green')" class="w-8 h-8 rounded-full color-option bg-green-500"></button>
|
||||
<button type="button" @click="selectColor('purple')" class="w-8 h-8 rounded-full color-option bg-purple-500"></button>
|
||||
<button type="button" @click="selectColor('blue')" data-color="blue" class="w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400" aria-label="Blue"></button>
|
||||
<button type="button" @click="selectColor('red')" data-color="red" class="w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400" aria-label="Red"></button>
|
||||
<button type="button" @click="selectColor('yellow')" data-color="yellow" class="w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400" aria-label="Yellow"></button>
|
||||
<button type="button" @click="selectColor('green')" data-color="green" class="w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400" aria-label="Green"></button>
|
||||
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
|
||||
</div>
|
||||
<input type="hidden" name="color" id="collection-color" value="blue"/>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="closeCollectionModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">Create Collection</button>
|
||||
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Collection</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@@ -29,128 +29,144 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center bg-black/70\"><div class=\"card rounded-lg p-6 w-full max-w-md mx-4\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex justify-between items-center mb-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if collection.ID != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Collection</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit Collection</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Collection</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Create Collection</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<button type=\"button\" @click=\"closeCollectionModal()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><button type=\"button\" @click=\"closeCollectionModal()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if collection.ID != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<form hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<form hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 16, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-redirect=\"/collections\"><input type=\"hidden\" name=\"id\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 19, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 25, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Optional description\" rows=\"3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 40, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"w-full px-4 py-2 border rounded-lg mb-2\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"🔍 Search or type emoji...\" maxlength=\"4\" oninput=\"filterIcons(this.value)\" onfocus=\"showAllIcons()\"><!-- Hidden input for form submission --><input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 56, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" class=\"w-8 h-8 rounded-full color-option bg-blue-500\"></button> <button type=\"button\" @click=\"selectColor('red')\" class=\"w-8 h-8 rounded-full color-option bg-red-500\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500\"></button> <button type=\"button\" @click=\"selectColor('green')\" class=\"w-8 h-8 rounded-full color-option bg-green-500\"></button> <button type=\"button\" @click=\"selectColor('purple')\" class=\"w-8 h-8 rounded-full color-option bg-purple-500\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 75, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Update Collection</button></div></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon</label><!-- Search/Text Input --><input type=\"text\" id=\"icon-search\" class=\"w-full px-4 py-2 border rounded-lg mb-2\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"🔍 Search or type emoji...\" maxlength=\"4\" oninput=\"filterIcons(this.value)\" onfocus=\"showAllIcons()\"><!-- Hidden input for form submission --><input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 122, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 116, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><!-- Emoji Grid --><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 border rounded-lg\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><!-- Populated dynamically by JavaScript --></div></div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" class=\"w-8 h-8 rounded-full color-option bg-blue-500\"></button> <button type=\"button\" @click=\"selectColor('red')\" class=\"w-8 h-8 rounded-full color-option bg-red-500\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500\"></button> <button type=\"button\" @click=\"selectColor('green')\" class=\"w-8 h-8 rounded-full color-option bg-green-500\"></button> <button type=\"button\" @click=\"selectColor('purple')\" class=\"w-8 h-8 rounded-full color-option bg-purple-500\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Cancel</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">Create Collection</button></div></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -9,16 +9,18 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
<title>Collection Rules - { collection.Name } - 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 }')">
|
||||
@Header(user, "/collections")
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<button @click="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
|
||||
← Back to Collection
|
||||
<button @click="backToCollection()" class="btn btn-secondary mb-4">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
Back to Collection
|
||||
</button>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||||
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</span>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
|
||||
@@ -28,8 +30,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
|
||||
<div id="rules-container" class="space-y-4">
|
||||
<div id="no-rules" class="card p-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="text-4xl mb-2">📋</div>
|
||||
<div id="no-rules" class="card p-8 text-center">
|
||||
<span class="inline-grid place-items-center h-12 w-12 rounded-xl mb-3" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("filter", "h-6 w-6")
|
||||
</span>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
|
||||
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
|
||||
</div>
|
||||
@@ -37,16 +41,15 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Create New Rule</h2>
|
||||
<form id="rule-form" @submit="handleCreateRule($event)">
|
||||
<form id="rule-form" @submit="handleCreateRule($event)" class="card p-6 space-y-6">
|
||||
<input type="hidden" id="collection-id" value="{ collection.ID }"/>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Field</label>
|
||||
<select
|
||||
id="rule-field"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select field...</option>
|
||||
<option value="genre">Genre</option>
|
||||
@@ -59,12 +62,11 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Operator</label>
|
||||
<select
|
||||
id="rule-operator"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select operator...</option>
|
||||
<option value="equals">equals</option>
|
||||
@@ -78,18 +80,17 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Value</label>
|
||||
<input
|
||||
type="text"
|
||||
id="rule-value"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="e.g., Science Fiction"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div>
|
||||
<label class="flex items-center space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -97,16 +98,16 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
checked
|
||||
class="w-5 h-5 rounded"
|
||||
/>
|
||||
<span style="color: var(--text-primary)">Enable Rule</span>
|
||||
<span class="text-sm font-medium" style="color: var(--text-primary)">Enable Rule</span>
|
||||
</label>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Uncheck to disable without deleting</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Priority</label>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Priority</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" name="priority" value="1" class="w-4 h-4"/>
|
||||
<span style="color: var(--text-primary)">High</span>
|
||||
<span class="text-sm" style="color: var(--text-primary)">High</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
@@ -116,7 +117,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
checked
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span style="color: var(--text-primary)">Medium</span>
|
||||
<span class="text-sm" style="color: var(--text-primary)">Medium</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
@@ -125,36 +126,38 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
value="3"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span style="color: var(--text-primary)">Low</span>
|
||||
<span class="text-sm" style="color: var(--text-primary)">Low</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
🧪 Test Rule
|
||||
<button type="button" @click="testRule()" class="btn btn-secondary">
|
||||
@Icon("search", "h-4 w-4")
|
||||
Test Rule
|
||||
</button>
|
||||
<button type="button" @click="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
<button type="button" @click="clearForm()" class="btn btn-ghost">
|
||||
Clear
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add Rule
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<div id="test-results" class="hidden card p-6 mt-4 mb-6">
|
||||
<h3 class="font-semibold mb-3" style="color: var(--text-primary)">Rule Test Results</h3>
|
||||
<p class="text-sm mb-2" style="color: var(--text-secondary)">Books that would be added by this rule:</p>
|
||||
<div id="test-results-list" class="max-h-64 overflow-y-auto space-y-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary)">Rule Examples</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">1</div>
|
||||
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">1</span>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p>
|
||||
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
||||
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
|
||||
Field: genre
|
||||
<br/>
|
||||
Operator: equals
|
||||
@@ -164,10 +167,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">2</div>
|
||||
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">2</span>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p>
|
||||
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
||||
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
|
||||
Field: series
|
||||
<br/>
|
||||
Operator: starts with
|
||||
@@ -177,10 +180,10 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">3</div>
|
||||
<span class="grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold" style="background-color: var(--accent-muted); color: var(--accent);">3</span>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary)">Add books published in a year range</p>
|
||||
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
|
||||
<code class="block mt-1 text-sm rounded-lg" style="color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;">
|
||||
Field: copyright_year
|
||||
<br/>
|
||||
Operator: greater than
|
||||
|
||||
File diff suppressed because one or more lines are too long
+198
-134
@@ -11,85 +11,93 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||
<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 }">
|
||||
@Header(user, "/collections")
|
||||
<!-- Modal Container -->
|
||||
<div id="modal-container"></div>
|
||||
<!-- Actual container page -->
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">My Collections</h1>
|
||||
<p style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8 flex flex-wrap justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("folder", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">My Collections</h1>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Organize your books into custom collections</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
hx-get="/collections/restore-modal"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn-secondary px-4 py-2 rounded-lg"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
🔄 Restore System
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
<span>Restore System</span>
|
||||
</button>
|
||||
<button
|
||||
hx-get="/collections/create-modal"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn-primary px-4 py-2 rounded-lg"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
➕ New Collection
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>New Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collections-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
if len(collections) == 0 {
|
||||
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📚</div>
|
||||
<div class="card text-center py-16 px-6 col-span-full">
|
||||
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("folder", "h-7 w-7")
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
||||
<p class="mb-4">Create collections to organize your books</p>
|
||||
<p class="mb-5" style="color: var(--text-secondary)">Create collections to organize your books</p>
|
||||
<button
|
||||
hx-get="/collections/create-modal"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn-primary px-4 py-2 rounded-lg"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Create Your First Collection
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>Create Your First Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
for _, col := range collections {
|
||||
<div @click="navigateToCollection($el)" data-href={ "/collections/" + col.ID } class="block">
|
||||
<div
|
||||
class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
||||
style="background-color: var(--bg-secondary);"
|
||||
class="card p-6 rounded-2xl border-l-4 cursor-pointer"
|
||||
data-color={ col.Color }
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="text-3xl">{ col.Icon }</div>
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||||
class="icon-btn"
|
||||
aria-label="Edit collection"
|
||||
>
|
||||
✏️
|
||||
@Icon("edit", "h-4 w-4")
|
||||
</button>
|
||||
<button
|
||||
hx-delete={ "/api/collections/" + col.ID + "" }
|
||||
hx-redirect="/collections"
|
||||
hx-confirm="Are you sure you want to delete this collection?"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-secondary); background-color: var(--bg-primary);"
|
||||
class="icon-btn"
|
||||
aria-label="Delete collection"
|
||||
>
|
||||
🗑️
|
||||
@Icon("trash", "h-4 w-4")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ col.Description }</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -109,127 +117,133 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<title>{ collection.Name } - 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 }">
|
||||
@Header(user, "/collections")
|
||||
@LibrarySwitcher(libData, libraryID)
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/collections" class="btn-secondary px-4 py-2 rounded-lg mb-4 inline-block">
|
||||
← Back to Collections
|
||||
<a href="/collections" class="btn btn-secondary mb-4">
|
||||
@Icon("arrow-left", "h-4 w-4")
|
||||
<span>Back to Collections</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
|
||||
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p style="color: var(--text-secondary)">{ collection.Description }</p>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Books in this Collection</h2>
|
||||
<span id="selected-count" class="hidden px-3 py-1 text-sm rounded" style="background-color: var(--accent); color: var(--bg-primary);">
|
||||
0 selected
|
||||
</span>
|
||||
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Books in this Collection</h2>
|
||||
<span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<div class="flex-1 min-w-[200px] max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
id="collection-search"
|
||||
placeholder="Search within collection..."
|
||||
onkeyup="filterCollectionBooks()"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
@input="filterCollectionBooks()"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="bulk-remove-btn"
|
||||
disabled
|
||||
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
🗑️ Remove Selected
|
||||
</button>
|
||||
<button
|
||||
@click="$store.bookPicker.open()"
|
||||
class="btn-primary px-4 py-2 rounded-lg"
|
||||
>
|
||||
➕ Add Books
|
||||
</button>
|
||||
if !collection.IsSystem {
|
||||
<button
|
||||
@click="requestBulkRemove()"
|
||||
:disabled="selectedBooks.length === 0"
|
||||
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="btn btn-danger"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove Selected</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$store.bookPicker.open()"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("plus", "h-4 w-4")
|
||||
<span>Add Books</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div id="books-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
if len(books) == 0 {
|
||||
<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>
|
||||
}
|
||||
for _, book := range books {
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<div
|
||||
class="card p-4 rounded-lg border hover:shadow-lg transition-shadow"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border);"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||
<div class="flex gap-4">
|
||||
if !collection.IsSystem {
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange="toggleBookForRemoval('{ book.MediaItemID }')"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-5 h-5"
|
||||
:checked="selectedBooks.includes('{ book.MediaItemID }')"
|
||||
@change="toggleSelection('{ book.MediaItemID }')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3
|
||||
class="font-semibold text-lg mb-1 line-clamp-2"
|
||||
style="color: var(--text-primary)"
|
||||
>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">
|
||||
{ book.Title }
|
||||
</h3>
|
||||
if book.Author != "" {
|
||||
<p
|
||||
class="text-sm line-clamp-1"
|
||||
style="color: var(--text-secondary)"
|
||||
>
|
||||
by { book.Author }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
</a>
|
||||
if book.Author != "" {
|
||||
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
|
||||
by { book.Author }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-16 sm:w-20">
|
||||
<a href={ "/media/" + book.MediaItemID }>
|
||||
if book.CoverImagePath != "" {
|
||||
<img
|
||||
src={ book.CoverImagePath }
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
onerror="this.src='/static/placeholder-book.svg'"
|
||||
/>
|
||||
} else {
|
||||
<img
|
||||
src="/static/placeholder-book.svg"
|
||||
alt="Cover"
|
||||
class="w-full aspect-[3/4] object-cover rounded shadow-md"
|
||||
class="w-full aspect-[3/4] object-cover rounded-lg shadow-md"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click="removeBook('{ book.MediaItemID }')"
|
||||
class="px-3 py-1 text-sm border rounded hover:opacity-80"
|
||||
style="border-color: var(--border); color: var(--text-secondary);"
|
||||
>
|
||||
🗑️ Remove from Collection
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
if !collection.IsSystem {
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||
<button
|
||||
@click="requestRemoveBook('{ book.MediaItemID }')"
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span>Remove from Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Book Picker Modal -->
|
||||
<div
|
||||
x-data="bookPicker"
|
||||
if !collection.IsSystem {
|
||||
<div
|
||||
x-data="bookPicker"
|
||||
x-show="$store.bookPicker.isOpen"
|
||||
@keyup.escape.window="$store.bookPicker.close()"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="display: none;"
|
||||
@click.self="$store.bookPicker.close()"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style="display: none; background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div
|
||||
@click.stop
|
||||
@@ -240,8 +254,8 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="card rounded-lg w-full max-w-6xl mx-4 my-8"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); display: none;"
|
||||
class="card rounded-2xl w-full max-w-6xl mx-4 my-8"
|
||||
style="display: none; box-shadow: var(--shadow-pop);"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between items-center p-6 border-b"
|
||||
@@ -252,23 +266,24 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</h2>
|
||||
<button
|
||||
@click="$store.bookPicker.close()"
|
||||
class="p-2 hover:opacity-80 rounded"
|
||||
style="color: var(--text-primary)"
|
||||
>✕</button>
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
>
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 border-b"
|
||||
style="border-color: var(--border);"
|
||||
>
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div id="book-picker-filters" class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
name="q"
|
||||
placeholder="Search books..."
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
||||
class="input"
|
||||
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-target="#book-picker-grid"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-include="#book-picker-filters"
|
||||
@@ -279,9 +294,8 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
type="text"
|
||||
name="author_filter"
|
||||
placeholder="Author"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
||||
class="input"
|
||||
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-target="#book-picker-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#book-picker-filters"
|
||||
@@ -292,32 +306,30 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
type="text"
|
||||
name="genre_filter"
|
||||
placeholder="Genre"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
hx-get="/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }"
|
||||
class="input"
|
||||
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-target="#book-picker-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#book-picker-filters"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
@click="$store.bookPicker.clearFilters()"
|
||||
class="px-4 py-2 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
✕ Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="filter-form" class="hidden">
|
||||
<button
|
||||
@click="$store.bookPicker.clearFilters()"
|
||||
class="btn btn-ghost"
|
||||
>
|
||||
@Icon("close", "h-4 w-4")
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
<input type="hidden" name="limit" value="50"/>
|
||||
<input type="hidden" name="offset" value="0"/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="book-picker-grid"
|
||||
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto"
|
||||
hx-get="/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }"
|
||||
hx-trigger="loadBooks"
|
||||
hx-include="#book-picker-filters"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
@@ -330,15 +342,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="$store.bookPicker.close()"
|
||||
class="px-4 py-2 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="$store.bookPicker.submit()"
|
||||
class="px-4 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Add Selected Books
|
||||
</button>
|
||||
@@ -346,12 +356,66 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
x-show="showConfirm"
|
||||
@click.self="closeConfirm()"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
style="display: none; background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div
|
||||
@click.stop
|
||||
class="card p-6 w-full max-w-md mx-4"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
>
|
||||
<h3 class="text-lg font-bold mb-2" style="color: var(--text-primary)">Remove from Collection</h3>
|
||||
<p x-text="confirmMessage" class="text-sm mb-6" style="color: var(--text-secondary)"></p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeConfirm()" class="btn btn-secondary">Cancel</button>
|
||||
<button @click="executeConfirmed()" class="btn btn-danger">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
<span x-text="confirmLabel"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<div
|
||||
id="collection-data"
|
||||
data-id={ collection.ID }
|
||||
data-library-id={ libraryID }
|
||||
data-is-system={ collection.IsSystem }
|
||||
style="display: none;"
|
||||
></div>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ BookPickerGrid(books []handlers.BookInfo) {
|
||||
for _, book := range books {
|
||||
<div
|
||||
class="relative cursor-pointer rounded-lg overflow-hidden"
|
||||
@click={ "$store.bookPicker.toggleBook('" + book.MediaItemID + "')" }
|
||||
>
|
||||
<div class="relative">
|
||||
if book.CoverImagePath != "" {
|
||||
<img src={ book.CoverImagePath } alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy" onerror="this.src='/static/placeholder-book.svg'"/>
|
||||
} else {
|
||||
<img src="/static/placeholder-book.svg" alt={ book.Title } class="w-full aspect-[2/3] object-cover" loading="lazy"/>
|
||||
}
|
||||
<div
|
||||
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
|
||||
class="absolute inset-0 border-4 rounded-lg pointer-events-none"
|
||||
style="border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);"
|
||||
></div>
|
||||
<div
|
||||
x-show={ "$store.bookPicker.isSelected('" + book.MediaItemID + "')" }
|
||||
class="absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none"
|
||||
style="background-color: var(--accent); color: var(--bg-primary);"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-primary)">{ book.Title }</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
+424
-70
@@ -31,7 +31,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>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 }\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -39,114 +39,170 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Modal Container --><div id=\"modal-container\"></div><!-- Actual container page --><div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex justify-between items-center\"><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">My Collections</h1><p style=\"color: var(--text-secondary)\">Organize your books into custom collections</p></div><div class=\"flex gap-3\"><button hx-get=\"/collections/restore-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-secondary px-4 py-2 rounded-lg\">🔄 Restore System</button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ New Collection</button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"modal-container\"></div><div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex flex-wrap justify-between items-center gap-4\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">My Collections</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Organize your books into custom collections</p></div></div><div class=\"flex gap-2\"><button hx-get=\"/collections/restore-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span>Restore System</span></button> <button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span>New Collection</span></button></div></div><div id=\"collections-list\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(collections) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"text-center py-16 col-span-full\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📚</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Collections Yet</h3><p class=\"mb-4\">Create collections to organize your books</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn-primary px-4 py-2 rounded-lg\">Create Your First Collection</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"card text-center py-16 px-6 col-span-full\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("folder", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Collections Yet</h3><p class=\"mb-5\" style=\"color: var(--text-secondary)\">Create collections to organize your books</p><button hx-get=\"/collections/create-modal\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span>Create Your First Collection</span></button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
for _, col := range collections {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div @click=\"navigateToCollection($el)\" data-href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div @click=\"navigateToCollection($el)\" data-href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 62, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"block\"><div class=\"card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary);\" data-color=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"block\"><div class=\"card p-6 rounded-2xl border-l-4 cursor-pointer\" data-color=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"><div class=\"flex justify-between items-start mb-4\"><div class=\"text-3xl\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 69, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"flex space-x-2\"><button hx-get=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"flex gap-1\"><button hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 72, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-secondary); background-color: var(--bg-primary);\">✏️</button> <button hx-delete=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn\" aria-label=\"Edit collection\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("edit", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button> <button hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 81, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-secondary); background-color: var(--bg-primary);\">🗑️</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"icon-btn\" aria-label=\"Delete collection\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 91, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 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, 10, "</h3><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 92, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</p></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -154,7 +210,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -183,20 +239,20 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
templ_7745c5c3_Var9 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 109, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -208,165 +264,326 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn-secondary px-4 py-2 rounded-lg mb-4 inline-block\">← Back to Collections</a><div class=\"flex items-center gap-4\"><div class=\"text-4xl\" style=\"color: { collection.Color }\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn btn-secondary mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span>Back to Collections</span></a><div class=\"flex items-center gap-4\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl text-3xl\" style=\"background-color: var(--accent-muted); color: { collection.Color }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 122, Col: 81}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 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, 17, "</div><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 124, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</h1><p style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 125, Col: 71}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p></div></div></div><div class=\"mb-6 flex justify-between items-center\"><div class=\"flex items-center gap-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span id=\"selected-count\" class=\"hidden px-3 py-1 text-sm rounded\" style=\"background-color: var(--accent); color: var(--bg-primary);\">0 selected</span></div><div class=\"flex gap-3\"><div class=\"flex-1 max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" onkeyup=\"filterCollectionBooks()\" class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><button id=\"bulk-remove-btn\" disabled class=\"btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed\">🗑️ Remove Selected</button> <button @click=\"$store.bookPicker.open()\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ Add Books</button></div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div></div><div class=\"mb-6 flex flex-wrap justify-between items-center gap-4\"><div class=\"flex items-center gap-3\"><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span x-show=\"selectedBooks.length > 0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\"></span></div><div class=\"flex flex-wrap gap-2 items-center\"><div class=\"flex-1 min-w-[200px] max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" @input=\"filterCollectionBooks()\" class=\"input\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !collection.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<button @click=\"requestBulkRemove()\" :disabled=\"selectedBooks.length === 0\" :class=\"selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''\" class=\"btn btn-danger\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Remove Selected</span></button> <button @click=\"$store.bookPicker.open()\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span>Add Books</span></button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(books) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div id=\"empty-state\" class=\"col-span-full text-center py-16\" style=\"color: var(--text-secondary)\">No books in this collection yet.</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div id=\"empty-state\" class=\"col-span-full card text-center py-16\" style=\"color: var(--text-secondary)\">No books in this collection yet.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
for _, book := range books {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card p-4 rounded-2xl collection-book-card\" data-title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 templ.SafeURL
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 167, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><div class=\"card p-4 rounded-lg border hover:shadow-lg transition-shadow\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex gap-4\"><div class=\"flex-shrink-0 pt-1\"><input type=\"checkbox\" onchange=\"toggleBookForRemoval('{ book.MediaItemID }')\" class=\"w-5 h-5\"></div><div class=\"flex-1 min-w-0\"><h3 class=\"font-semibold text-lg mb-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" data-author=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 185, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h3>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-media-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 147}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"><div class=\"flex gap-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !collection.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"flex-shrink-0 pt-1\"><label class=\"flex items-center cursor-pointer p-2 -m-2\"><input type=\"checkbox\" class=\"w-5 h-5\" :checked=\"selectedBooks.includes('{ book.MediaItemID }')\" @change=\"toggleSelection('{ book.MediaItemID }')\"></label></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex-1 min-w-0\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.Author != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 192, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 templ.SafeURL
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 209, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded shadow-md\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></div><div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"removeBook('{ book.MediaItemID }')\" class=\"px-3 py-1 text-sm border rounded hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-secondary);\">🗑️ Remove from Collection</button></div></div></a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !collection.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"requestRemoveBook('{ book.MediaItemID }')\" class=\"btn btn-secondary w-full\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span>Remove from Collection</span></button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div><!-- Book Picker Modal --><div x-data=\"bookPicker\" @keyup.escape.window=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center\" style=\"display: none;\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-lg w-full max-w-6xl mx-4 my-8\" style=\"background-color: var(--bg-secondary); border-color: var(--border); display: none;\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"p-2 hover:opacity-80 rounded\" style=\"color: var(--text-primary)\">✕</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div class=\"flex flex-wrap gap-4 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"search\" placeholder=\"Search books...\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" hx-get=\"/api/media-items/filtered?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div><button @click=\"$store.bookPicker.clearFilters()\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">✕ Clear</button></div></div><form id=\"filter-form\" class=\"hidden\"><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"px-4 py-2 rounded-lg border\" style=\"border-color: var(--border); color: var(--text-primary);\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent); color: var(--bg-primary);\">Add Selected Books</button></div></div></div></div></body><div id=\"collection-data\" data-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 352, Col: 26}
|
||||
if !collection.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div x-data=\"bookPicker\" x-show=\"$store.bookPicker.isOpen\" @keyup.escape.window=\"$store.bookPicker.close()\" @click.self=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-2xl w-full max-w-6xl mx-4 my-8\" style=\"display: none; box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div id=\"book-picker-filters\" class=\"flex flex-wrap gap-3 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"q\" placeholder=\"Search books...\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><button @click=\"$store.bookPicker.clearFilters()\" class=\"btn btn-ghost\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>Clear</span></button> <input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></div></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-trigger=\"loadBooks\" hx-include=\"#book-picker-filters\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"btn btn-primary\">Add Selected Books</button></div></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div x-show=\"showConfirm\" @click.self=\"closeConfirm()\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><h3 class=\"text-lg font-bold mb-2\" style=\"color: var(--text-primary)\">Remove from Collection</h3><p x-text=\"confirmMessage\" class=\"text-sm mb-6\" style=\"color: var(--text-secondary)\"></p><div class=\"flex justify-end gap-3\"><button @click=\"closeConfirm()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"executeConfirmed()\" class=\"btn btn-danger\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" data-library-id=\"")
|
||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span x-text=\"confirmLabel\"></span></button></div></div></div></body><div id=\"collection-data\" data-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" style=\"display: none;\"></div></html>")
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" style=\"display: none;\"></div></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -374,4 +591,141 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
})
|
||||
}
|
||||
|
||||
func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var25 == nil {
|
||||
templ_7745c5c3_Var25 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
for _, book := range books {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"relative cursor-pointer rounded-lg overflow-hidden\" @click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 397, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\"><div class=\"relative\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if book.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 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
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
|
||||
+83
-69
@@ -11,58 +11,70 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
<title>Sync Conflicts - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="conflicts" class="theme-{ user.Theme }">
|
||||
@Header(user, "/conflicts")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||
<p style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<select
|
||||
id="status-filter"
|
||||
onchange="filterConflicts()"
|
||||
class="px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="unresolved">Unresolved Only</option>
|
||||
<option value="all">All Conflicts</option>
|
||||
<option value="resolved">Resolved Only</option>
|
||||
</select>
|
||||
<button @click="dismissAllResolved()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Dismiss All Resolved
|
||||
</button>
|
||||
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stats-bar" class="mt-4 flex space-x-6 text-sm">
|
||||
<span style="color: var(--text-secondary)">Total: <strong style="color: var(--text-primary)">{ total }</strong></span>
|
||||
<span style="color: var(--text-secondary)">Unresolved: <strong style="color: #f59e0b;">{ unresolved }</strong></span>
|
||||
<span style="color: var(--text-secondary)">Resolved: <strong style="color: #10b981;">{ total - unresolved }</strong></span>
|
||||
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bulk-actions" class="mt-4 hidden">
|
||||
<div class="flex items-center justify-between p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
||||
<span id="selected-count">0</span> conflicts selected
|
||||
</span>
|
||||
<select
|
||||
id="bulk-strategy"
|
||||
class="px-3 py-2 border rounded-lg text-sm"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input w-auto"
|
||||
>
|
||||
<option value="most_recent">Most Recent</option>
|
||||
<option value="highest_progress">Highest Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="bulkResolve()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
✅ Resolve Selected
|
||||
<button @click="bulkResolve()" class="btn btn-primary text-sm">
|
||||
@Icon("check", "h-4 w-4")
|
||||
Resolve Selected
|
||||
</button>
|
||||
<button @click="bulkDismiss()" class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||
❌ Dismiss Selected
|
||||
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
|
||||
Dismiss Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,12 +84,14 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading conflicts...</p>
|
||||
</div>
|
||||
<div id="empty-state" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<div id="empty-state" class="hidden card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||
@Icon("check-circle", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||
<p>Your devices are in sync! No conflicts to resolve.</p>
|
||||
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||
</div>
|
||||
<div id="conflicts-list" class="space-y-6">
|
||||
<div id="conflicts-list" class="space-y-4">
|
||||
<div id="select-all-container" class="mb-4 hidden">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
|
||||
@@ -85,15 +99,17 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</label>
|
||||
</div>
|
||||
if len(conflicts) == 0 {
|
||||
<div class="text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<div class="card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||
@Icon("check-circle", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||
<p>Your devices are in sync! No conflicts to resolve.</p>
|
||||
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||
</div>
|
||||
}
|
||||
for _, conflict := range conflicts {
|
||||
<div class="conflict-item card p-6 rounded-lg border" data-conflict-id="{ conflict.ID }" style="background-color: var(--bg-secondary); border-color: var(--border); border-left-width: 4px; border-left-style: solid; border-left-color: rgb(245, 158, 11);">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="conflict-item card p-6" data-conflict-id="{ conflict.ID }" style="border-left-width: 4px; border-left-color: var(--status-warning);">
|
||||
<div class="flex justify-between items-start mb-4 gap-4">
|
||||
<div class="flex items-start space-x-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -101,14 +117,17 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
data-conflict-id="{ conflict.ID }"
|
||||
onchange="updateBulkActions()"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
||||
<div class="flex items-start gap-3">
|
||||
<span style="color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showResolveModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Resolve
|
||||
</button>
|
||||
<button @click="showResolveModal('{ conflict.ID }')" class="btn btn-primary">Resolve</button>
|
||||
</div>
|
||||
<div class="text-sm" style="color: var(--text-secondary)">
|
||||
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
||||
@@ -119,94 +138,90 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
|
||||
<p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p>
|
||||
</div>
|
||||
<button @click="hideResolveModal()" class="p-2 hover:opacity-80 rounded-lg" style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||||
X
|
||||
<button @click="hideResolveModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<form id="conflict-resolution-form" @submit="handleResolveSubmit($event)">
|
||||
<input type="hidden" id="conflict-id"/>
|
||||
<div id="conflict-details" class="mb-6"></div>
|
||||
<div class="card p-4 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||
<div class="card p-4 mb-6">
|
||||
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3>
|
||||
<div class="space-y-3 mb-6">
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="koreader" class="mr-3" required/>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="koreader" class="w-4 h-4" required/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="kobo" class="mr-3"/>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="kobo" class="w-4 h-4"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="web" class="mr-3"/>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="web" class="w-4 h-4"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center p-3 border rounded-lg cursor-pointer hover:opacity-80" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="manual" class="mr-3"/>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
||||
<input type="radio" name="winner" value="manual" class="w-4 h-4"/>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div id="manual-override" class="hidden mb-4 p-4 border rounded-lg" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div id="manual-override" class="hidden mb-4 p-4 rounded-lg" style="background-color: var(--bg-primary); border: 1px solid var(--border);">
|
||||
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="manual-percentage"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Page Number</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Page Number</label>
|
||||
<input
|
||||
type="number"
|
||||
id="manual-page"
|
||||
min="1"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
|
||||
<input
|
||||
type="text"
|
||||
id="manual-epubcfi"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Chapter</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Chapter</label>
|
||||
<input
|
||||
type="number"
|
||||
id="manual-chapter"
|
||||
min="1"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,20 +233,19 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</label>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="resolution-reason"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="e.g., Device was offline, using most recent progress"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideResolveModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button type="button" @click="deleteConflict()" class="btn-danger px-4 py-2 rounded-lg">Dismiss</button>
|
||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg">Resolve Conflict</button>
|
||||
<div class="flex justify-end space-x-3 flex-wrap">
|
||||
<button type="button" @click="hideResolveModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" @click="deleteConflict()" class="btn btn-danger">Dismiss</button>
|
||||
<button type="submit" class="btn btn-primary">Resolve Conflict</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,78 +9,86 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
||||
<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 }">
|
||||
@Header(user, "/custom-section")
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
|
||||
<p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
|
||||
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("layers", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Create Custom Section</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
|
||||
</div>
|
||||
<form id="custom-section-form" class="space-y-6">
|
||||
<!-- Section Details -->
|
||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
|
||||
<div class="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("info", "h-5 w-5")
|
||||
Section Details
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
|
||||
<label for="section-name" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="section-name"
|
||||
name="name"
|
||||
required
|
||||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
|
||||
<label for="section-icon" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="section-icon"
|
||||
name="icon"
|
||||
maxlength="4"
|
||||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="📚"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||
<label for="section-description" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||
<textarea
|
||||
id="section-description"
|
||||
name="description"
|
||||
rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
|
||||
<label for="section-library" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
|
||||
<select
|
||||
id="section-library"
|
||||
name="library_id"
|
||||
required
|
||||
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select a library...</option>
|
||||
<option value="">Select a library…</option>
|
||||
for _, lib := range libraries {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter Rules -->
|
||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("filter", "h-5 w-5")
|
||||
Filter Rules
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
id="add-rule-btn"
|
||||
class="px-3 py-1 rounded-lg text-sm font-medium"
|
||||
style="background-color: var(--accent);"
|
||||
class="btn btn-primary text-sm"
|
||||
>
|
||||
+ Add Rule
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||||
@@ -88,101 +96,101 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
||||
</p>
|
||||
<div id="rules-container" class="space-y-3"></div>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
|
||||
<label for="match-type" class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Match:</label>
|
||||
<select
|
||||
id="match-type"
|
||||
name="match_type"
|
||||
class="px-3 py-1 rounded border"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input w-auto"
|
||||
>
|
||||
<option value="all">ALL rules (AND)</option>
|
||||
<option value="any">ANY rule (OR)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manual Book Selection -->
|
||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
|
||||
<div class="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("book", "h-5 w-5")
|
||||
Manual Book Selection
|
||||
</h2>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||||
Add specific books to this section. Use the search to find and select multiple books.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
|
||||
<label for="book-search" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="book-search"
|
||||
name="book_search"
|
||||
class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="Search by title or author..."
|
||||
class="input flex-1"
|
||||
placeholder="Search by title or author…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="search-books-btn"
|
||||
class="px-4 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent);"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("search", "h-4 w-4")
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="search-results"
|
||||
class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
|
||||
class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2"
|
||||
style="background-color: var(--bg-primary);"
|
||||
></div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
|
||||
<label class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
|
||||
<div
|
||||
id="selected-books"
|
||||
class="min-h-[60px] p-3 rounded-lg border-2 border-dashed"
|
||||
style="border-color: var(--border); background-color: var(--bg-primary);"
|
||||
class="min-h-[60px] p-3 rounded-xl border-2 border-dashed"
|
||||
style="border-color: var(--border-strong); background-color: var(--bg-primary);"
|
||||
>
|
||||
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Live Preview -->
|
||||
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("play", "h-5 w-5")
|
||||
Live Preview
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
id="preview-btn"
|
||||
class="px-4 py-2 rounded-lg font-medium"
|
||||
style="background-color: var(--accent);"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Refresh Preview
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="preview-container"
|
||||
class="p-4 rounded-lg"
|
||||
class="card p-4"
|
||||
style="background-color: var(--bg-primary); min-height: 200px;"
|
||||
>
|
||||
<p class="text-center" style="color: var(--text-secondary);">
|
||||
<p class="text-center text-sm" style="color: var(--text-secondary);">
|
||||
Add filter rules or select books to see a preview of your custom section.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-btn"
|
||||
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
id="save-section-btn"
|
||||
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
|
||||
style="background-color: var(--accent);"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
Save Section
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>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 }\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,43 +37,115 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Create Custom Section</h1><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p><form id=\"custom-section-form\" class=\"space-y-6\"><!-- Section Details --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><h2 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></textarea></div><div class=\"mt-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select a library...</option> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8 max-w-4xl\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("layers", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Create Custom Section</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p></div><form id=\"custom-section-form\" class=\"space-y-6\"><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label for=\"section-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"input\"></div><div><label for=\"section-icon\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"input\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label for=\"section-description\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"input\"></textarea></div><div class=\"mt-4\"><label for=\"section-library\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"input\"><option value=\"\">Select a library…</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</select></div></div><!-- Filter Rules --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"px-3 py-1 rounded-lg text-sm font-medium\" style=\"background-color: var(--accent);\">+ Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"px-3 py-1 rounded border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><!-- Manual Book Selection --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><h2 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Search by title or author...\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent);\">Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-lg border-2 border-dashed\" style=\"border-color: var(--border); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><!-- Live Preview --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent);\">Refresh Preview</button></div><div id=\"preview-container\" class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><!-- Form Actions --><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"px-6 py-2 rounded-lg font-medium border hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"px-6 py-2 rounded-lg font-medium text-white hover:opacity-90\" style=\"background-color: var(--accent);\">Save Section</button></div></form></main>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("filter", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"btn btn-primary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label for=\"match-type\" class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"input w-auto\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><div class=\"card p-6\"><h2 class=\"text-lg font-semibold mb-4 flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("book", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label for=\"book-search\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"input flex-1\" placeholder=\"Search by title or author…\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("play", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"btn btn-secondary text-sm\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Refresh Preview</button></div><div id=\"preview-container\" class=\"card p-4\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center text-sm\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Save Section</button></div></form></main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -81,7 +153,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+78
-89
@@ -14,17 +14,20 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
<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 }">
|
||||
@Header(user, "/dashboard")
|
||||
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
|
||||
<!-- Collections Container -->
|
||||
<main id="collections-container" class="w-full px-4 py-8">
|
||||
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p>
|
||||
</div>
|
||||
for _, section := range sections {
|
||||
@CollectionCarousel(section)
|
||||
}
|
||||
</main>
|
||||
<!-- Dashboard Settings Modal -->
|
||||
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
@@ -33,89 +36,79 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
|
||||
|
||||
templ CollectionCarousel(section handlers.SectionData) {
|
||||
<div
|
||||
class="dashboard-collection mb-8"
|
||||
class="dashboard-collection mb-10"
|
||||
data-collection-id={ section.ID }
|
||||
data-is-system={ section.IsSystem }
|
||||
>
|
||||
<!-- Collection Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-end justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">{ section.Icon }</span>
|
||||
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted);">{ section.Icon }</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
|
||||
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">{ section.Title }</h2>
|
||||
if section.Description != "" {
|
||||
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
|
||||
<p class="text-xs" style="color: var(--text-secondary)">{ section.Description }</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if section.ViewAllURL != "" {
|
||||
<a
|
||||
href={ section.ViewAllURL }
|
||||
class="text-sm font-medium hover:underline transition-colors"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
View All →
|
||||
View All
|
||||
@Icon("arrow-right", "h-4 w-4")
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<!-- Carousel -->
|
||||
<div class="carousel-container relative group">
|
||||
<button
|
||||
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
|
||||
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
|
||||
flex items-center justify-start opacity-0 group-hover:opacity-100
|
||||
transition-opacity duration-200"
|
||||
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
|
||||
data-action="scroll-carousel"
|
||||
data-collection-id={ section.ID }
|
||||
data-direction="-1"
|
||||
aria-label="Scroll left"
|
||||
style="background: linear-gradient(to right, var(--bg-primary), transparent);"
|
||||
>
|
||||
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
|
||||
@Icon("chevron-left", "h-7 w-7")
|
||||
</button>
|
||||
<div
|
||||
id={ "carousel-track-" + section.ID }
|
||||
class="carousel-track flex gap-4 overflow-x-auto
|
||||
scroll-smooth snap-x snap-mandatory
|
||||
px-12 pb-4"
|
||||
class="carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2"
|
||||
style="scrollbar-width: none; -ms-overflow-style: none;"
|
||||
>
|
||||
for _, item := range section.Items {
|
||||
@BookCard(item)
|
||||
<div class="flex-shrink-0 w-36 sm:w-40 snap-start">
|
||||
@BookCard(item)
|
||||
</div>
|
||||
}
|
||||
if len(section.Items) == 0 {
|
||||
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
|
||||
<p>No items in this collection</p>
|
||||
<div class="flex flex-col items-center justify-center text-center py-12 w-full gap-2" style="color: var(--text-secondary);">
|
||||
@Icon("book", "h-8 w-8 opacity-50")
|
||||
<p class="text-sm">No items in this collection</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
|
||||
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
|
||||
flex items-center justify-end opacity-0 group-hover:opacity-100
|
||||
transition-opacity duration-200"
|
||||
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
|
||||
data-action="scroll-carousel"
|
||||
data-collection-id={ section.ID }
|
||||
data-direction="1"
|
||||
aria-label="Scroll right"
|
||||
style="background: linear-gradient(to left, var(--bg-primary), transparent);"
|
||||
>
|
||||
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
||||
@Icon("chevron-right", "h-7 w-7")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ BookCard(item handlers.BookInfo) {
|
||||
<a href={ "/media/" + item.MediaItemID } title={ item.Title }>
|
||||
<div
|
||||
class="book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer
|
||||
transition-transform duration-200 hover:scale-105"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label={ "View " + item.Title }
|
||||
>
|
||||
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer">
|
||||
<a href={ "/media/" + item.MediaItemID } class="block h-full">
|
||||
<div
|
||||
class="aspect-[2/3] overflow-hidden shadow-lg
|
||||
bg-gradient-to-br from-gray-700 to-gray-900"
|
||||
class="book-card-cover aspect-[2/3] overflow-hidden"
|
||||
style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);"
|
||||
>
|
||||
if item.CoverImagePath != "" {
|
||||
<img
|
||||
@@ -133,60 +126,72 @@ templ BookCard(item handlers.BookInfo) {
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div class="book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]">
|
||||
<h3 class="font-semibold text-base line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||
<div class="book-card-meta">
|
||||
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
||||
{ item.Title }
|
||||
</h3>
|
||||
if item.Author != "" {
|
||||
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
|
||||
<p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
|
||||
{ item.Author }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
<div class="book-card-action">
|
||||
if item.HasConflict {
|
||||
<a
|
||||
href={ "/media/" + item.MediaItemID }
|
||||
class="book-card-action-btn"
|
||||
aria-label={ "Resolve progress conflict for " + item.Title }
|
||||
title="Resolve progress conflict"
|
||||
>
|
||||
@Icon("book-open", "h-5 w-5")
|
||||
</a>
|
||||
} else {
|
||||
<a
|
||||
href={ "/readers/" + item.MediaItemID }
|
||||
class="book-card-action-btn"
|
||||
aria-label={ "Read " + item.Title }
|
||||
title="Read"
|
||||
>
|
||||
@Icon("book-open", "h-5 w-5")
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
||||
<div
|
||||
id="dashboard-settings-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background-color: rgba(0, 0, 0, 0.7);"
|
||||
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div
|
||||
class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
|
||||
style="background-color: var(--bg-secondary);"
|
||||
>
|
||||
<div class="card p-6 w-full max-w-2xl shadow-2xl" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
||||
<button
|
||||
data-action="close-dashboard-settings"
|
||||
class="p-2 hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
✕
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
|
||||
</div>
|
||||
<button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary);">
|
||||
Drag to reorder collections, toggle visibility with the switch.
|
||||
</p>
|
||||
<!-- Draggable Collection List -->
|
||||
<div id="collection-list" class="space-y-2 mb-6">
|
||||
for _, section := range sections {
|
||||
<div
|
||||
class="collection-item flex items-center justify-between p-3 rounded border
|
||||
cursor-move select-none"
|
||||
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card"
|
||||
data-collection-id={ section.ID }
|
||||
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||||
draggable="true"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
|
||||
@Icon("grip", "h-5 w-5 shrink-0")
|
||||
<span class="text-xl">{ section.Icon }</span>
|
||||
<div>
|
||||
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
|
||||
if section.IsSystem {
|
||||
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,8 +200,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
<button
|
||||
data-action="restore-system-collection"
|
||||
data-collection-name={ section.ID }
|
||||
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
|
||||
style="border-color: var(--border); color: var(--text-secondary);"
|
||||
class="btn btn-secondary py-1 px-2.5 text-xs"
|
||||
title="Restore { section.Title } to defaults"
|
||||
>
|
||||
Restore
|
||||
@@ -211,21 +215,17 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
}
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
|
||||
peer-checked:bg-blue-600"
|
||||
class="w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
|
||||
style="background-color: var(--border-strong);"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Items Per Section Slider -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Items per Collection: <span id="items-count-display" class="font-bold">{ itemsPerSection }</span>
|
||||
Items per Collection: <span id="items-count-display" class="font-bold" style="color: var(--text-primary)">{ itemsPerSection }</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@@ -233,26 +233,15 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
max="50"
|
||||
step="5"
|
||||
value={ itemsPerSection }
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||
class="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
||||
style="background-color: var(--border-strong);"
|
||||
data-input-action="update-items-count"
|
||||
target="items-count-display"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
data-action="close-dashboard-settings"
|
||||
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
|
||||
style="border-color: var(--border); color: var(--text-primary);"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-action="save-dashboard-settings"
|
||||
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
|
||||
style="background-color: var(--accent);"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
|
||||
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+282
-169
@@ -34,7 +34,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>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 }\">")
|
||||
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 }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<!-- Collections Container --><main id=\"collections-container\" class=\"w-full px-4 py-8\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main id=\"collections-container\" class=\"w-full px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Your Library</h1><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Pick up where you left off, or explore something new.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main><!-- Dashboard Settings Modal -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -97,14 +97,14 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-8\" data-collection-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"dashboard-collection mb-10\" data-collection-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -117,33 +117,33 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 41, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><!-- Collection Header --><div class=\"flex items-center justify-between mb-4\"><div class=\"flex items-center gap-3\"><span class=\"text-2xl\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"><div class=\"flex items-end justify-between mb-4\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-9 w-9 rounded-lg text-lg\" style=\"background-color: var(--accent-muted);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 43, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 130}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 100}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -154,14 +154,14 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.Description != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<p class=\"text-xs\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -184,31 +184,47 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var8 templ.SafeURL
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 53, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All →</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("arrow-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div><!-- Carousel --><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 w-12 h-full bg-gradient-to-r from-gray-900 to-transparent flex items-center justify-start opacity-0 group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><div class=\"carousel-container relative group\"><button class=\"carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-direction=\"-1\" aria-label=\"Scroll left\"><span class=\"text-3xl pl-2\" style=\"color: var(--text-primary);\">‹</span></button><div id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" data-direction=\"-1\" aria-label=\"Scroll left\" style=\"background: linear-gradient(to right, var(--bg-primary), transparent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chevron-left", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button><div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -221,36 +237,60 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-12 pb-4\" style=\"scrollbar-width: none; -ms-overflow-style: none;\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2\" style=\"scrollbar-width: none; -ms-overflow-style: none;\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range section.Items {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"flex-shrink-0 w-36 sm:w-40 snap-start\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = BookCard(item).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(section.Items) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"text-center py-8 w-full\" style=\"color: var(--text-secondary);\"><p>No items in this collection</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"flex flex-col items-center justify-center text-center py-12 w-full gap-2\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("book", "h-8 w-8 opacity-50").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"text-sm\">No items in this collection</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><button class=\"carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 w-12 h-full bg-gradient-to-l from-gray-900 to-transparent flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><button class=\"carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200\" data-action=\"scroll-carousel\" data-collection-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-direction=\"1\" aria-label=\"Scroll right\"><span class=\"text-3xl pr-2\" style=\"color: var(--text-primary);\">›</span></button></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" data-direction=\"1\" aria-label=\"Scroll right\" style=\"background: linear-gradient(to left, var(--bg-primary), transparent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chevron-right", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -279,162 +319,219 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
templ_7745c5c3_Var12 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"><div class=\"book-card flex-shrink-0 w-36 rounded-lg overflow-hidden snap-start cursor-pointer transition-transform duration-200 hover:scale-105\" tabindex=\"0\" role=\"button\" aria-label=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("View " + item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"><div class=\"aspect-[2/3] overflow-hidden shadow-lg bg-gradient-to-br from-gray-700 to-gray-900\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"block h-full\"><div class=\"book-card-cover aspect-[2/3] overflow-hidden\" style=\"background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 116, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 122, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"w-full h-full object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"w-full h-full object-cover\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"w-full h-full object-cover\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div><div class=\"book-card-text px-2 py-1 bg-[color-mix(in_srgb,var(--wood-border)_40%,transparent)]\"><h3 class=\"font-semibold text-base line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div><div class=\"book-card-meta\"><h3 class=\"font-semibold text-sm leading-snug line-clamp-2\" style=\"color: var(--text-primary)\" title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h3>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.Author != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<p class=\"text-xs mt-0.5 line-clamp-1\" style=\"color: var(--text-secondary)\" title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 141, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 100}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 142, Col: 19}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 19}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></div></a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div></a><div class=\"book-card-action\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.HasConflict {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 templ.SafeURL
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" title=\"Resolve progress conflict\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 templ.SafeURL
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 152, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"book-card-action-btn\" aria-label=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 154, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" title=\"Read\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -458,143 +555,159 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var23 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var23 == nil {
|
||||
templ_7745c5c3_Var23 = templ.NopComponent
|
||||
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var25 == nil {
|
||||
templ_7745c5c3_Var25 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center\" style=\"background-color: rgba(0, 0, 0, 0.7);\"><div class=\"rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><button data-action=\"close-dashboard-settings\" class=\"p-2 hover:bg-gray-700 rounded transition-colors\">✕</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary);\">Drag to reorder collections, toggle visibility with the switch.</p><!-- Draggable Collection List --><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button></div><div id=\"collection-list\" class=\"space-y-2 mb-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, section := range sections {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"collection-item flex items-center justify-between p-3 rounded border cursor-move select-none\" data-collection-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-is-system=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" draggable=\"true\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"text-xl\" style=\"color: var(--text-secondary);\">☰</span> <span class=\"text-xl\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div class=\"collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span><div><span class=\"font-medium\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" data-is-system=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 187, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" draggable=\"true\"><div class=\"flex items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("grip", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"text-xl\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span><div class=\"flex items-center gap-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 192, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"text-xs ml-2 px-2 py-1 rounded\" style=\"background-color: var(--accent);\">System</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div><div class=\"flex items-center gap-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div><div class=\"flex items-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<button data-action=\"restore-system-collection\" data-collection-name=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity\" style=\"border-color: var(--border); color: var(--text-secondary);\" title=\"Restore { section.Title } to defaults\">Restore</button> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" class=\"btn btn-secondary py-1 px-2.5 text-xs\" title=\"Restore { section.Title } to defaults\">Restore</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<label class=\"relative inline-flex items-center cursor-pointer\"><input type=\"checkbox\" class=\"sr-only peer\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !ContainsString(hiddenCollections, section.ID) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " checked")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><div class=\"w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600\"></div></label></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "><div class=\"w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all\" style=\"background-color: var(--border-strong);\"></div></label></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div><!-- Items Per Section Slider --><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div><div class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Items per Collection: <span id=\"items-count-display\" class=\"font-bold\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span></label> <input type=\"range\" min=\"10\" max=\"50\" step=\"5\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors\" style=\"border-color: var(--border); color: var(--text-primary);\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity\" style=\"background-color: var(--accent);\">Save Changes</button></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+178
-156
@@ -11,59 +11,62 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
<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()">
|
||||
@Header(user, "/devices")
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header Section -->
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">Device Management</h1>
|
||||
<p style="color: var(--text-secondary)">Manage your reading devices and sync settings</p>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("device", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Device Management</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Manage your reading devices and sync settings</p>
|
||||
</div>
|
||||
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add New Device
|
||||
<button @click="showAddDeviceModal()" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add New Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Devices Grid -->
|
||||
<div id="devices-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Empty state -->
|
||||
if len(devices) == 0 {
|
||||
<div id="empty-state" class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
||||
<div class="text-6xl mb-4">📱</div>
|
||||
<div id="empty-state" class="card text-center py-16 col-span-full">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("device", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Devices Yet</h3>
|
||||
<p class="mb-4">Add your reading devices to enable cross-device sync</p>
|
||||
<button @click="showAddDeviceModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Add Your First Device
|
||||
</button>
|
||||
<p class="mb-4 text-sm" style="color: var(--text-secondary)">Add your reading devices to enable cross-device sync</p>
|
||||
<button @click="showAddDeviceModal()" class="btn btn-primary">Add Your First Device</button>
|
||||
</div>
|
||||
}
|
||||
<!-- Devices Grid -->
|
||||
if len(devices) > 0 {
|
||||
<div id="devices-grid" class="grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div id="devices-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 col-span-full">
|
||||
for _, device := range devices {
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="text-4xl">
|
||||
<span class="grid place-items-center h-11 w-11 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
if device.DeviceType == "koreader" {
|
||||
📖
|
||||
@Icon("book-open", "h-5 w-5")
|
||||
} else if device.DeviceType == "kobo" {
|
||||
📚
|
||||
@Icon("library", "h-5 w-5")
|
||||
} else if device.DeviceType == "web" {
|
||||
🌐
|
||||
@Icon("globe", "h-5 w-5")
|
||||
} else {
|
||||
📱
|
||||
@Icon("device", "h-5 w-5")
|
||||
}
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||
📚
|
||||
</button>
|
||||
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||
⚙️
|
||||
</span>
|
||||
<div class="flex space-x-1">
|
||||
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="icon-btn" title="Shelf mappings" aria-label="Shelf mappings">
|
||||
@Icon("layers", "h-5 w-5")
|
||||
</button>
|
||||
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="icon-btn" title="Device settings" aria-label="Device settings">
|
||||
@Icon("gear", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-1" style="color: var(--text-primary)">{ device.DeviceName }</h3>
|
||||
@@ -72,9 +75,9 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
<div class="flex justify-between">
|
||||
<span style="color: var(--text-secondary)">Sync Status</span>
|
||||
if device.SyncEnabled {
|
||||
<span style="color: var(--accent)">✓ Enabled</span>
|
||||
<span class="badge status-completed">Enabled</span>
|
||||
} else {
|
||||
<span style="color: var(--text-secondary)">✗ Disabled</span>
|
||||
<span class="badge" style="background-color: var(--surface-hover); color: var(--text-secondary);">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
@@ -82,7 +85,7 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
if device.LastSync != nil {
|
||||
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSync, user.Timezone) }</span>
|
||||
} else {
|
||||
<span style="color: var(--text-primary)">Never</span>
|
||||
<span style="color: var(--text-secondary)">Never</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
@@ -90,15 +93,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
if device.LastSeen != nil {
|
||||
<span style="color: var(--text-primary)">{ FormatInTimezone(*device.LastSeen, user.Timezone) }</span>
|
||||
} else {
|
||||
<span style="color: var(--text-primary)">Never</span>
|
||||
<span style="color: var(--text-secondary)">Never</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- NEW: Sync URL & Token Management -->
|
||||
<div class="border-t pt-4" style="border-color: var(--border);">
|
||||
<p class="text-xs font-semibold mb-2" style="color: var(--text-secondary)">DEVICE SYNC CONFIGURATION</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide mb-3" style="color: var(--text-secondary)">Device Sync Configuration</p>
|
||||
if device.DeviceType == "kobo" {
|
||||
<!-- Kobo: Copy Full Sync URL -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label>
|
||||
<div class="flex space-x-2">
|
||||
@@ -107,22 +108,20 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
id="sync-url-{ device.ID }"
|
||||
readonly
|
||||
value={ baseURL + "/api/sync/kobo/" + device.AuthToken }
|
||||
class="flex-1 px-3 py-2 text-xs rounded border"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input text-xs font-mono"
|
||||
/>
|
||||
<button
|
||||
@click="copyToClipboard(document.getElementById('sync-url-{ device.ID }').value, 'Kobo sync URL')"
|
||||
class="px-3 py-2 text-xs rounded hover:opacity-80"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary text-xs px-3"
|
||||
>
|
||||
📋 Copy
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary);">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">api_endpoint</code> setting</p>
|
||||
</div>
|
||||
}
|
||||
if device.DeviceType == "koreader" {
|
||||
<!-- KOReader: Copy Auth Token -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label>
|
||||
<div class="flex space-x-2">
|
||||
@@ -131,43 +130,40 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
id="auth-token-{ device.ID }"
|
||||
readonly
|
||||
value={ device.AuthToken }
|
||||
class="flex-1 px-3 py-2 text-xs rounded border"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border); font-family: monospace;"
|
||||
class="input text-xs font-mono"
|
||||
/>
|
||||
<button
|
||||
@click="copyToClipboard(document.getElementById('auth-token-{ device.ID }').value, 'Auth token')"
|
||||
class="px-3 py-2 text-xs rounded hover:opacity-80"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary text-xs px-3"
|
||||
>
|
||||
📋 Copy
|
||||
@Icon("copy", "h-4 w-4")
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Regenerate Token Button -->
|
||||
<button
|
||||
hx-put={ "/api/devices/" + device.ID.String() + "/regenerate-token" }
|
||||
hx-confirm="⚠️ Old token will immediately stop working. Regenerate anyway?"
|
||||
class="w-full px-3 py-2 text-xs rounded border hover:opacity-80"
|
||||
style="border-color: var(--border); color: var(--text-secondary); background-color: var(--bg-primary);"
|
||||
hx-confirm="Old token will immediately stop working. Regenerate anyway?"
|
||||
class="btn btn-secondary w-full text-xs"
|
||||
>
|
||||
🔄 Regenerate Token
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
Regenerate Token
|
||||
</button>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary);">⚠️ Old token will immediately stop working</p>
|
||||
<p class="text-xs mt-1" style="color: var(--status-warning);">Old token will immediately stop working</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Pending Registrations -->
|
||||
if len(pendingRegistrations) > 0 {
|
||||
<div id="pending-section" class="mt-12">
|
||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2>
|
||||
<h2 class="text-lg font-bold tracking-tight mb-4" style="color: var(--text-primary)">Pending Device Registrations</h2>
|
||||
<div class="space-y-4">
|
||||
for _, reg := range pendingRegistrations {
|
||||
<div class="card p-4 rounded-lg border flex items-center justify-between" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">{ reg.DeviceName }</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
@@ -175,10 +171,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn btn-primary text-sm">
|
||||
Approve
|
||||
</button>
|
||||
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn btn-danger text-sm">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
@@ -187,92 +183,134 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Sync Queue Section -->
|
||||
<div id="sync-queue-section" class="mt-12 hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Queue</h2>
|
||||
<button @click="clearSyncQueue()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Clear Queue
|
||||
</button>
|
||||
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Sync Queue</h2>
|
||||
<button @click="clearSyncQueue()" class="btn btn-secondary">Clear Queue</button>
|
||||
</div>
|
||||
<div id="sync-queue" class="space-y-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Device Modal -->
|
||||
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2>
|
||||
<button @click="hideAddDeviceModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<div id="add-device-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" x-data="{ selectedType: '' }" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add New Device</h2>
|
||||
<button @click="hideAddDeviceModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Type</label>
|
||||
<select
|
||||
id="device-type"
|
||||
x-model="selectedType"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select device type...</option>
|
||||
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
|
||||
<option value="kobo">Kobo E-Reader</option>
|
||||
<!-- <option value="web">Web Browser</option> -->
|
||||
<!-- <option value="mobile">Mobile App</option> -->
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="selectedType === 'koreader'" x-cloak class="space-y-4">
|
||||
<div class="rounded-lg p-4 space-y-4" style="background-color: var(--surface-secondary);">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2" style="color: var(--text-primary)">
|
||||
@Icon("book-open", "h-4 w-4")
|
||||
KOReader Plugin Setup
|
||||
</h3>
|
||||
<ol class="space-y-3 text-sm" style="color: var(--text-secondary)">
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">1</span>
|
||||
<span>Clone the <a href="https://git.linuxhg.com/Bookhoard/bookhoard.koplugin" class="underline" style="color: var(--accent);">Bookhoard plugin</a> to your KOReader <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">plugins/</code> directory</span>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">2</span>
|
||||
<span>Open KOReader, tap the <strong>wrench icon</strong> at the top</span>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">3</span>
|
||||
<span>Find and tap <strong>Bookhoard sync</strong></span>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">4</span>
|
||||
<span>Tap <strong>Server URL</strong>, enter your server address, then tap <strong>OK</strong>:</span>
|
||||
</li>
|
||||
<li class="ml-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs px-2 py-1 rounded font-mono flex-1" style="background-color: var(--bg-primary); color: var(--text-primary);">{ baseURL }</code>
|
||||
<button
|
||||
@click={ "copyToClipboard('" + baseURL + "', 'Server URL')" }
|
||||
class="btn btn-secondary text-xs px-3"
|
||||
>
|
||||
@Icon("copy", "h-4 w-4")
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 grid place-items-center h-5 w-5 rounded-full text-xs font-bold" style="background-color: var(--accent-muted); color: var(--accent);">5</span>
|
||||
<span>Return to this page and refresh — you'll see a <strong>pending registration</strong>. Click <strong>Approve</strong> to connect the device.</span>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="rounded-md p-3 text-xs flex items-start gap-2" style="background-color: var(--accent-muted); color: var(--text-secondary);">
|
||||
@Icon("info", "h-4 w-4 flex-shrink-0 mt-0.5")
|
||||
<span>Once approved, reading progress sync and OPDS catalog access are set up automatically.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" @click="hideAddDeviceModal()" class="btn btn-primary">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="selectedType === 'kobo'" x-cloak>
|
||||
<form id="add-device-form" @submit="handleAddDevice($event)">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="device-name"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
placeholder="My Kindle Paperwhite"
|
||||
class="input"
|
||||
placeholder="My Kobo"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Type</label>
|
||||
<select
|
||||
id="device-type"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
>
|
||||
<option value="">Select device type...</option>
|
||||
<option value="koreader">KOReader (Kindle, Kobo, PocketBook)</option>
|
||||
<option value="kobo">Kobo E-Reader</option>
|
||||
<option value="web">Web Browser</option>
|
||||
<option value="mobile">Mobile App</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Identifier</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Identifier</label>
|
||||
<input
|
||||
type="text"
|
||||
id="device-identifier"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="Hardware ID or Serial Number"
|
||||
/>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Enter your device's unique identifier</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideAddDeviceModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Register Device
|
||||
</button>
|
||||
<button type="button" @click="hideAddDeviceModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Register Device</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Settings Modal -->
|
||||
<div id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
</div>
|
||||
<div id="device-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Device Settings</h2>
|
||||
<button @click="hideDeviceSettingsModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<button @click="hideDeviceSettingsModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<form id="device-settings-form" @submit="handleSaveDeviceSettings($event)">
|
||||
<input type="hidden" id="settings-device-id"/>
|
||||
<input type="hidden" id="settings-device-type"/>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="settings-device-name"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
@@ -300,11 +338,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Automatically sync changes</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Frequency</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Frequency</label>
|
||||
<select
|
||||
id="settings-sync-frequency"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="1">Every 1 minute</option>
|
||||
<option value="5" selected>Every 5 minutes</option>
|
||||
@@ -314,14 +351,13 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</select>
|
||||
</div>
|
||||
<div class="border-t pt-6 mb-6" style="border-color: var(--border);">
|
||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Collection View Settings</h3>
|
||||
<h3 class="text-base font-semibold mb-1" style="color: var(--text-primary)">Collection View Settings</h3>
|
||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">Configure how collections are displayed on this device</p>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Default View Mode</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default View Mode</label>
|
||||
<select
|
||||
id="settings-view-mode"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="grid">Grid View</option>
|
||||
<option value="list">List View</option>
|
||||
@@ -329,11 +365,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sort Collections By</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sort Collections By</label>
|
||||
<select
|
||||
id="settings-sort-order"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="name">Collection Name</option>
|
||||
<option value="created">Date Created</option>
|
||||
@@ -342,11 +377,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Items Per Page</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Items Per Page</label>
|
||||
<select
|
||||
id="settings-items-per-page"
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="12">12 items</option>
|
||||
<option value="24" selected>24 items</option>
|
||||
@@ -378,81 +412,73 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
<p class="text-xs mt-1 ml-8" style="color: var(--text-secondary)">Display progress indicators for books</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideDeviceSettingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" @click="handleRevokeDevice()" class="btn-danger px-4 py-2 rounded-lg">
|
||||
Revoke Device
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Save Settings
|
||||
</button>
|
||||
<div class="flex justify-end space-x-3 flex-wrap">
|
||||
<button type="button" @click="hideDeviceSettingsModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" @click="handleRevokeDevice()" class="btn btn-danger">Revoke Device</button>
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Shelf Mappings Modal -->
|
||||
<div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div id="shelf-mappings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Collection to Shelf Mappings</h2>
|
||||
<button @click="hideShelfMappingsModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<button @click="hideShelfMappingsModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="mappings-device-id"/>
|
||||
<div id="shelf-mappings-container" class="space-y-4 mb-6">
|
||||
<p style="color: var(--text-secondary)">Loading mappings...</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideShelfMappingsModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Close
|
||||
</button>
|
||||
<button type="button" @click="showAddMappingModal()" class="btn-primary px-4 py-2 rounded-lg">
|
||||
➕ Add Mapping
|
||||
<button type="button" @click="hideShelfMappingsModal()" class="btn btn-secondary">Close</button>
|
||||
<button type="button" @click="showAddMappingModal()" class="btn btn-primary">
|
||||
@Icon("plus", "h-4 w-4")
|
||||
Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add/Edit Shelf Mapping Modal -->
|
||||
<div id="add-mapping-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div id="add-mapping-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Collection Mapping</h2>
|
||||
<button @click="hideAddMappingModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
<button @click="hideAddMappingModal()" class="icon-btn" aria-label="Close">
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<form id="mapping-form" @submit="handleSaveMapping($event)">
|
||||
<input type="hidden" id="mapping-device-id"/>
|
||||
<input type="hidden" id="mapping-id"/>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Collection</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Collection</label>
|
||||
<select
|
||||
id="mapping-collection"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Select collection...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Device Shelf Name</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device Shelf Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mapping-shelf-name"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
placeholder="e.g., Sci-Fi"
|
||||
/>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Name of the shelf on this device</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Sync Direction</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Direction</label>
|
||||
<select
|
||||
id="mapping-sync-direction"
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="bidirectional">Bidirectional (sync both ways)</option>
|
||||
<option value="book_to_device">Bookhoard → Device</option>
|
||||
@@ -461,12 +487,8 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="hideAddMappingModal()" class="btn-secondary px-4 py-2 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||||
Save Mapping
|
||||
</button>
|
||||
<button type="button" @click="hideAddMappingModal()" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Mapping</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+203
-53
File diff suppressed because one or more lines are too long
+85
-83
@@ -10,103 +10,105 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ doc.Title } - 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>
|
||||
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased" }>
|
||||
<body x-data="docs" x-init="initializeSearch(); highlightCurrentPage(); initializeCodeCopyButtons()" class={ "theme-" + user.Theme + " page-docs font-sans antialiased" }>
|
||||
@Header(user, currentPath)
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80"
|
||||
class="lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised"
|
||||
onclick="toggleSidebar()"
|
||||
aria-label="Toggle menu"
|
||||
aria-label="Toggle documentation menu"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</button>
|
||||
<!-- Search Results Overlay -->
|
||||
<div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out"></div>
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out">
|
||||
<!-- Search -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm"
|
||||
placeholder="Search documentation..."
|
||||
id="docs-search"
|
||||
/>
|
||||
</div>
|
||||
<!-- Navigation Sections -->
|
||||
for _, section := range nav.Sections {
|
||||
<div class="nav-section mb-6">
|
||||
<div
|
||||
class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between"
|
||||
@click="toggleSection($el)"
|
||||
>
|
||||
<span>{ section.Title }</span>
|
||||
<svg class="w-4 h-4 transform transition-transform section-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
<div id="docs-overlay" class="hidden fixed inset-0 z-40 lg:hidden" style="background: var(--surface-overlay);" onclick="toggleSidebar()"></div>
|
||||
<div id="search-results" class="hidden fixed inset-0 z-50 overflow-y-auto p-8" style="background: var(--surface-overlay);"></div>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex gap-8 py-8">
|
||||
<aside id="docs-sidebar" class="sidebar fixed lg:sticky top-16 lg:top-20 bottom-0 lg:bottom-auto left-0 lg:left-auto z-40 lg:z-10 w-64 shrink-0 lg:h-[calc(100vh-6rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line lg:border-r-0">
|
||||
<div class="p-4 border-b border-line">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input input pl-9"
|
||||
placeholder="Search documentation…"
|
||||
id="docs-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
if section.Collapsed {
|
||||
<div class="nav-items hidden" data-collapsed="true">
|
||||
for _, item := range section.Items {
|
||||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="nav-items" data-collapsed="false">
|
||||
for _, item := range section.Items {
|
||||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||||
</a>
|
||||
for _, section := range nav.Sections {
|
||||
<div class="nav-section border-b border-line">
|
||||
<div
|
||||
class="nav-section-title flex items-center justify-between px-4 py-3 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
@click="toggleSection($el)"
|
||||
>
|
||||
<span>{ section.Title }</span>
|
||||
@Icon("chevron-down", "h-4 w-4 section-arrow transition-transform")
|
||||
</div>
|
||||
if section.Collapsed {
|
||||
<div class="nav-items hidden" data-collapsed="true">
|
||||
for _, item := range section.Items {
|
||||
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
|
||||
<span>{ item.Title }</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="nav-items" data-collapsed="false">
|
||||
for _, item := range section.Items {
|
||||
<a href={ item.URL } class="nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||
<span class="text-xs" style="color: var(--accent);">{ item.Icon }</span>
|
||||
<span>{ item.Title }</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Main Content -->
|
||||
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
if len(doc.Breadcrumb) > 0 {
|
||||
<nav class="breadcrumb flex gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
|
||||
for i, crumb := range doc.Breadcrumb {
|
||||
if i > 0 {
|
||||
<span class="text-text-secondary/50">›</span>
|
||||
}
|
||||
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
|
||||
</aside>
|
||||
<main class="main-content flex-1 min-w-0 max-w-4xl">
|
||||
if len(doc.Breadcrumb) > 0 {
|
||||
<nav class="breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8" aria-label="Breadcrumb" style="color: var(--text-secondary);">
|
||||
for i, crumb := range doc.Breadcrumb {
|
||||
if i > 0 {
|
||||
@Icon("chevron-right", "h-4 w-4")
|
||||
}
|
||||
<a href={ crumb.URL } class="no-underline transition-colors hover:underline" style="color: var(--accent);">{ crumb.Title }</a>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
<!-- Title -->
|
||||
<h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1>
|
||||
<!-- Table of Contents -->
|
||||
if len(doc.TOC) > 0 {
|
||||
<details class="toc bg-background-secondary p-4 rounded-lg mb-8 border border-border">
|
||||
<summary class="cursor-pointer hover:opacity-80 transition-opacity">
|
||||
<strong class="text-text-primary font-semibold">On this page</strong>
|
||||
<span class="ml-2 text-text-secondary text-xs">▼</span>
|
||||
</summary>
|
||||
for _, item := range doc.TOC {
|
||||
<a
|
||||
href={ "#" + item.Anchor }
|
||||
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
|
||||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
||||
>
|
||||
{ item.Title }
|
||||
</a>
|
||||
<h1 class="text-4xl font-bold mb-8 tracking-tight" style="color: var(--text-primary);">{ doc.Title }</h1>
|
||||
if len(doc.TOC) > 0 {
|
||||
<details class="toc card p-4 mb-8">
|
||||
<summary class="cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl" style="color: var(--text-primary);">
|
||||
<span>On this page</span>
|
||||
@Icon("chevron-down", "h-4 w-4 ml-auto")
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
for _, item := range doc.TOC {
|
||||
<a
|
||||
href={ "#" + item.Anchor }
|
||||
class="toc-item block py-1 text-sm no-underline transition-colors hover:text-brand"
|
||||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);" }
|
||||
>
|
||||
{ item.Title }
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
<!-- Content -->
|
||||
<div class="prose prose-invert max-w-none">
|
||||
@UnsafeHTML(doc.Content).ToComponent()
|
||||
<div class="prose prose-invert max-w-none">
|
||||
@UnsafeHTML(doc.Content).ToComponent()
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
+81
-49
@@ -44,11 +44,11 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard Documentation</title><link href=\"/static/style.css\" rel=\"stylesheet\"><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=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><link rel=\"stylesheet\" href=\"/static/highlight-dark.min.css\"></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased"}
|
||||
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " page-docs font-sans antialiased"}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
@@ -74,267 +74,299 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<!-- Mobile Menu Button --><button class=\"lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80\" onclick=\"toggleSidebar()\" aria-label=\"Toggle menu\"><svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 12h16M4 18h16\"></path></svg></button><!-- Search Results Overlay --><div id=\"search-results\" class=\"hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out\"></div><!-- Sidebar --><div class=\"sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out\"><!-- Search --><div class=\"p-4 border-b border-border\"><input type=\"text\" class=\"search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm\" placeholder=\"Search documentation...\" id=\"docs-search\"></div><!-- Navigation Sections -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<button class=\"lg:hidden icon-btn fixed top-[4.75rem] left-4 z-50 bg-surface-raised\" onclick=\"toggleSidebar()\" aria-label=\"Toggle documentation menu\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("menu", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</button><div id=\"docs-overlay\" class=\"hidden fixed inset-0 z-40 lg:hidden\" style=\"background: var(--surface-overlay);\" onclick=\"toggleSidebar()\"></div><div id=\"search-results\" class=\"hidden fixed inset-0 z-50 overflow-y-auto p-8\" style=\"background: var(--surface-overlay);\"></div><div class=\"mx-auto max-w-7xl px-4 sm:px-6 lg:px-8\"><div class=\"flex gap-8 py-8\"><aside id=\"docs-sidebar\" class=\"sidebar fixed lg:sticky top-16 lg:top-20 bottom-0 lg:bottom-auto left-0 lg:left-auto z-40 lg:z-10 w-64 shrink-0 lg:h-[calc(100vh-6rem)] overflow-y-auto -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out bg-surface-raised border-r border-line lg:border-r-0\"><div class=\"p-4 border-b border-line\"><div class=\"relative\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> <input type=\"text\" class=\"search-input input pl-9\" placeholder=\"Search documentation…\" id=\"docs-search\"></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, section := range nav.Sections {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"nav-section mb-6\"><div class=\"nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between\" @click=\"toggleSection($el)\"><span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"nav-section border-b border-line\"><div class=\"nav-section-title flex items-center justify-between px-4 py-3 cursor-pointer select-none text-xs uppercase tracking-wider font-semibold transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" @click=\"toggleSection($el)\"><span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 47, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> <svg class=\"w-4 h-4 transform transition-transform section-arrow\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path></svg></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4 section-arrow transition-transform").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.Collapsed {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"nav-items hidden\" data-collapsed=\"true\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range section.Items {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 55, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline\"><span class=\"mr-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\"><span class=\"text-xs\" style=\"color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"nav-items\" data-collapsed=\"false\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"nav-items\" data-collapsed=\"false\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range section.Items {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 templ.SafeURL
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 63, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline\"><span class=\"mr-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" class=\"nav-item flex items-center gap-2 py-2 pl-8 pr-4 text-sm no-underline transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\"><span class=\"text-xs\" style=\"color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 64, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><!-- Main Content --><main class=\"main-content lg:ml-72 p-8 max-w-4xl mx-auto\"><!-- Breadcrumb -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside><main class=\"main-content flex-1 min-w-0 max-w-4xl\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(doc.Breadcrumb) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<nav class=\"breadcrumb flex gap-2 text-sm text-text-secondary mb-8\" aria-label=\"Breadcrumb\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<nav class=\"breadcrumb flex flex-wrap items-center gap-1.5 text-sm mb-8\" aria-label=\"Breadcrumb\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i, crumb := range doc.Breadcrumb {
|
||||
if i > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"text-text-secondary/50\">›</span>")
|
||||
templ_7745c5c3_Err = Icon("chevron-right", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " <a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " <a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 templ.SafeURL
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"text-accent hover:underline no-underline\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"no-underline transition-colors hover:underline\" style=\"color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 81, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</nav>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</nav>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<!-- Title --><h1 class=\"text-4xl font-bold mb-8 text-text-primary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<h1 class=\"text-4xl font-bold mb-8 tracking-tight\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h1><!-- Table of Contents -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(doc.TOC) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<details class=\"toc bg-background-secondary p-4 rounded-lg mb-8 border border-border\"><summary class=\"cursor-pointer hover:opacity-80 transition-opacity\"><strong class=\"text-text-primary font-semibold\">On this page</strong> <span class=\"ml-2 text-text-secondary text-xs\">▼</span></summary> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<details class=\"toc card p-4 mb-8\"><summary class=\"cursor-pointer list-none [&::-webkit-details-marker]:hidden flex items-center gap-2 font-semibold transition-colors hover:bg-surface-hover -m-4 p-4 rounded-2xl\" style=\"color: var(--text-primary);\"><span>On this page</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("chevron-down", "h-4 w-4 ml-auto").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</summary><div class=\"mt-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range doc.TOC {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline\" style=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"toc-item block py-1 text-sm no-underline transition-colors hover:text-brand\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level))
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</details>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<!-- Content --><div class=\"prose prose-invert max-w-none\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"prose prose-invert max-w-none\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -342,7 +374,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div></main></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ templ ErrorPage(message string, errorType string) {
|
||||
<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>
|
||||
* {
|
||||
margin: 0;
|
||||
|
||||
@@ -29,14 +29,14 @@ func ErrorPage(message string, errorType string) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>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\">")
|
||||
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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 83, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 89, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
package templates
|
||||
|
||||
// FilterItem renders a single saved filter item for the list
|
||||
templ FilterItem(id string, name string) {
|
||||
<div
|
||||
class="flex items-center justify-between p-2 rounded hover:opacity-80"
|
||||
style="background-color: var(--bg-primary);"
|
||||
class="flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover"
|
||||
data-filter-id={ id }
|
||||
>
|
||||
<button
|
||||
data-action="load-filter"
|
||||
@click="loadFilter($event)"
|
||||
class="flex-1 text-left px-2 py-1 rounded"
|
||||
class="flex-1 text-left px-2 py-1 text-sm rounded-lg hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
{ name }
|
||||
@@ -18,11 +16,12 @@ templ FilterItem(id string, name string) {
|
||||
<button
|
||||
data-action="delete-filter"
|
||||
@click="deleteFilter($event)"
|
||||
class="p-1 hover:opacity-70 rounded"
|
||||
class="icon-btn shrink-0"
|
||||
style="color: var(--text-secondary);"
|
||||
title="Delete filter"
|
||||
aria-label="Delete filter"
|
||||
>
|
||||
🗑
|
||||
@Icon("trash", "h-4 w-4")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ package templates
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
// FilterItem renders a single saved filter item for the list
|
||||
func FilterItem(id string, name string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
@@ -30,33 +29,41 @@ func FilterItem(id string, name string) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between p-2 rounded hover:opacity-80\" style=\"background-color: var(--bg-primary);\" data-filter-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-surface-hover\" data-filter-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 8, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 6, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 rounded\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><button data-action=\"load-filter\" @click=\"loadFilter($event)\" class=\"flex-1 text-left px-2 py-1 text-sm rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 16, Col: 9}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 14, Col: 9}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"p-1 hover:opacity-70 rounded\" style=\"color: var(--text-secondary);\" title=\"Delete filter\">🗑</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</button> <button data-action=\"delete-filter\" @click=\"deleteFilter($event)\" class=\"icon-btn shrink-0\" style=\"color: var(--text-secondary);\" title=\"Delete filter\" aria-label=\"Delete filter\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+239
-507
@@ -1,531 +1,263 @@
|
||||
package templates
|
||||
|
||||
templ Header(user User, currentPath string) {
|
||||
<nav x-data="header" x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()" class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);">
|
||||
<div x-data="{}" x-init="console.log('Alpine is working!', $el)"></div>
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- Left: App Title & Navigation -->
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity flex items-center gap-2" style="color: var(--text-primary); text-decoration: none;">
|
||||
<span>📚 Bookhoard</span>
|
||||
<!-- Scan spinner -->
|
||||
<svg
|
||||
x-show="scanning"
|
||||
x-transition
|
||||
class="h-5 w-5 animate-spin"
|
||||
style="color: var(--accent);"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span x-show="scanning && scanProgress > 0" x-transition class="text-xs font-normal" style="color: var(--text-secondary);" x-text="scanProgress + '%'"></span>
|
||||
</a>
|
||||
<div class="hidden nav:flex items-center space-x-4">
|
||||
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Library
|
||||
</a>
|
||||
<a
|
||||
href="/bookshelf"
|
||||
class="text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-secondary); text-decoration: none;"
|
||||
>
|
||||
All Books
|
||||
</a>
|
||||
<a href="/series" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Series
|
||||
</a>
|
||||
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Collections
|
||||
</a>
|
||||
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Progress
|
||||
</a>
|
||||
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Devices
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Center: Search Box -->
|
||||
<div class="flex-1 max-w-2xl mx-8 hidden nav:block">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="header-search"
|
||||
placeholder="Search your library..."
|
||||
class="w-full px-4 py-2 pl-10 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Theme Switcher & User Menu -->
|
||||
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="hidden nav:flex items-center space-x-4">
|
||||
<!-- Theme Switcher -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="themeDropdownOpen = !themeDropdownOpen"
|
||||
type="button"
|
||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-primary);"
|
||||
>
|
||||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
x-show="themeDropdownOpen"
|
||||
@click.outside="themeDropdownOpen = false"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
||||
>
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
@click="changeTheme('tokyo-night'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
|
||||
Tokyo Night
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('dracula'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
|
||||
Dracula
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('nord'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
|
||||
Nord
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('solarized-dark'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
|
||||
Solarized Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('monokai'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
|
||||
Monokai
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('one-dark-pro'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
|
||||
One Dark Pro
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('material-dark'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary); background-color: var(--bg-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
|
||||
Material Dark
|
||||
</button>
|
||||
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
|
||||
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
|
||||
<button
|
||||
@click="changeWoodPaneling('none'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="none"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-light'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-light"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-light.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Light
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-dark'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-dark"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-mahogany'); themeDropdownOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-mahogany"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2"
|
||||
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"
|
||||
></span>
|
||||
Wood Mahogany
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User Icon with Dropdown -->
|
||||
<div class="relative">
|
||||
if user.ID != "" {
|
||||
<!-- LOGGED IN: Show user menu with logout -->
|
||||
<div
|
||||
x-data="header"
|
||||
x-init="initializeSearch(); initializeTheme(); loadWoodPaneling(); updateWoodPanelingIndicators(); restoreLibrarySelection(); initializeScanListener()"
|
||||
>
|
||||
<!-- Mobile backdrop -->
|
||||
<div
|
||||
class="app-sidebar-backdrop lg:hidden"
|
||||
:class="{ 'is-open': mobileMenuOpen }"
|
||||
@click="mobileMenuOpen = false"
|
||||
x-cloak
|
||||
></div>
|
||||
<!-- Sidebar -->
|
||||
<aside class="app-sidebar" :class="{ 'is-open': mobileMenuOpen }">
|
||||
<!-- Logo -->
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="flex items-center gap-2.5 px-5 h-16 shrink-0 border-b"
|
||||
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
|
||||
>
|
||||
<span class="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
|
||||
x-show="scanning"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="h-4 w-4 animate-spin ml-auto"
|
||||
style="color: var(--accent);"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<span
|
||||
x-show="scanning && scanProgress > 0"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="block px-5 py-1.5 text-xs font-medium -mt-1"
|
||||
style="color: var(--text-secondary);"
|
||||
x-text="'Scanning… ' + scanProgress + '%'"
|
||||
></span>
|
||||
<!-- Primary navigation -->
|
||||
<nav class="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
<a href="/dashboard" class={ activeClass(currentPath, "/dashboard") }>
|
||||
@Icon("library", "h-5 w-5 shrink-0")
|
||||
<span>Library</span>
|
||||
</a>
|
||||
<a href="/bookshelf" class={ activeClass(currentPath, "/bookshelf") }>
|
||||
@Icon("book", "h-5 w-5 shrink-0")
|
||||
<span>All Books</span>
|
||||
</a>
|
||||
<a href="/series" class={ activeClass(currentPath, "/series") }>
|
||||
@Icon("layers", "h-5 w-5 shrink-0")
|
||||
<span>Series</span>
|
||||
</a>
|
||||
<a href="/collections" class={ activeClass(currentPath, "/collections") }>
|
||||
@Icon("folder", "h-5 w-5 shrink-0")
|
||||
<span>Collections</span>
|
||||
</a>
|
||||
<a href="/progress" class={ activeClass(currentPath, "/progress") }>
|
||||
@Icon("clock", "h-5 w-5 shrink-0")
|
||||
<span>Progress</span>
|
||||
</a>
|
||||
<a href="/devices" class={ activeClass(currentPath, "/devices") }>
|
||||
@Icon("device", "h-5 w-5 shrink-0")
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
</nav>
|
||||
<!-- Bottom: theme picker + user -->
|
||||
<div
|
||||
class="shrink-0 px-3 py-3 space-y-2 border-t"
|
||||
style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"
|
||||
>
|
||||
if user.ID != "" {
|
||||
@SidebarUserMenu(user)
|
||||
} else {
|
||||
@SidebarSignIn(currentPath)
|
||||
}
|
||||
<!-- Theme picker -->
|
||||
<div x-data="{ themeOpen: false }" class="sidebar-panel">
|
||||
<button
|
||||
type="button"
|
||||
@click="themeOpen = !themeOpen"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-content-muted hover:text-content hover:bg-surface-hover"
|
||||
>
|
||||
@Icon("palette", "h-5 w-5 shrink-0")
|
||||
<span>Appearance</span>
|
||||
@Icon("chevron-down", "h-4 w-4 ml-auto transition-transform")
|
||||
</button>
|
||||
<div x-show="themeOpen" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
|
||||
for _, opt := range ThemeOptions {
|
||||
<button
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
type="button"
|
||||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-primary);"
|
||||
@click={ "changeTheme('" + opt.Name + "'); themeOpen = false" }
|
||||
class="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"
|
||||
data-theme={ opt.Name }
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
|
||||
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style={ "background-color: " + opt.Color }></span>
|
||||
<span class="flex-1 text-left">{ opt.Label }</span>
|
||||
<span class={ "theme-check shrink-0" + themeCheckClass(opt.Name, user.Theme) }>
|
||||
@Icon("check", "h-4 w-4")
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
x-show="userMenuOpen"
|
||||
@click.outside="userMenuOpen = false"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
||||
>
|
||||
<div class="py-1">
|
||||
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
|
||||
Profile
|
||||
</a>
|
||||
if user.Role == "admin" {
|
||||
<a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
|
||||
Admin Panel
|
||||
</a>
|
||||
}
|
||||
<div class="border-t my-1" style="border-color: var(--border);"></div>
|
||||
<button
|
||||
@click="logout(); userMenuOpen = false"
|
||||
type="button"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
|
||||
style="color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- LOGGED OUT: Show login form -->
|
||||
}
|
||||
<div class="my-1.5 mx-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);"></div>
|
||||
<p class="px-3 pb-1 text-xs font-medium uppercase tracking-wide" style="color: var(--text-secondary);">Bookshelf</p>
|
||||
for _, w := range WoodOptions {
|
||||
<button
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
type="button"
|
||||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-primary);"
|
||||
@click={ "changeWoodPaneling('" + w.Name + "')" }
|
||||
class="wood-paneling-btn w-full flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
data-wood={ w.Name }
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
|
||||
<span class="inline-block w-3.5 h-3.5 rounded-full shrink-0 ring-1 ring-black/10" style="background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);"></span>
|
||||
<span class="flex-1 text-left">{ w.Label }</span>
|
||||
</button>
|
||||
<div
|
||||
x-show="userMenuOpen"
|
||||
@click.outside="userMenuOpen = false"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
|
||||
>
|
||||
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
|
||||
<input type="hidden" name="redirect" value="{ currentPath }"/>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<div id="login-result"></div>
|
||||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||||
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Don't have an account? Sign Up
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
type="button"
|
||||
class="nav:hidden p-2 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary);"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
x-show="mobileMenuOpen"
|
||||
@click.outside="mobileMenuOpen = false"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="nav:hidden border-t"
|
||||
style="border-color: var(--border); background-color: var(--bg-secondary); display: none;"
|
||||
x-data="{ mobileThemeOpen: false }"
|
||||
>
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
<div class="relative mb-3">
|
||||
<input
|
||||
type="text"
|
||||
id="header-search-mobile"
|
||||
placeholder="Search your library..."
|
||||
class="w-full px-4 py-2 pl-10 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<a href="/dashboard" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Library
|
||||
</a>
|
||||
<a href="/bookshelf" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
All Books
|
||||
</a>
|
||||
<a href="/series" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Series
|
||||
</a>
|
||||
<a href="/collections" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Collections
|
||||
</a>
|
||||
<a href="/progress" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Progress
|
||||
</a>
|
||||
<a href="/devices" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Devices
|
||||
</a>
|
||||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||||
<!-- Admin section (visible to admins only) -->
|
||||
if user.Role == "admin" {
|
||||
<div x-data="{ adminOpen: window.location.pathname.startsWith('/admin') }" class="sidebar-panel">
|
||||
<button
|
||||
@click="mobileThemeOpen = !mobileThemeOpen"
|
||||
type="button"
|
||||
class="flex items-center space-x-2 w-full px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-secondary);"
|
||||
@click="adminOpen = !adminOpen"
|
||||
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"
|
||||
>
|
||||
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||||
</svg>
|
||||
<span>Theme</span>
|
||||
<svg class="h-4 w-4 ml-auto transform transition-transform" :class="{ 'rotate-180': mobileThemeOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
@Icon("shield", "h-5 w-5 shrink-0")
|
||||
<span>Administration</span>
|
||||
<svg
|
||||
class="h-4 w-4 ml-auto transition-transform"
|
||||
:class="{ 'rotate-180': adminOpen }"
|
||||
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="mobileThemeOpen" x-transition class="pl-8 pr-2 py-1 space-y-1">
|
||||
<button
|
||||
@click="changeTheme('tokyo-night'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
|
||||
Tokyo Night
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('dracula'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
|
||||
Dracula
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('nord'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
|
||||
Nord
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('solarized-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
|
||||
Solarized Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('monokai'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
|
||||
Monokai
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('one-dark-pro'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
|
||||
One Dark Pro
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('material-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
|
||||
Material Dark
|
||||
</button>
|
||||
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
|
||||
<p class="text-xs mb-2 px-3" style="color: var(--text-secondary)">Bookshelf Background</p>
|
||||
<button
|
||||
@click="changeWoodPaneling('none'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="none"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-light'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-light"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
|
||||
Wood Light
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-dark"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
|
||||
Wood Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-mahogany'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-mahogany"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
|
||||
Wood Mahogany
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="adminOpen" 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 class="border-t my-2" style="border-color: var(--border);"></div>
|
||||
if user.ID != "" {
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2 px-3 py-2">
|
||||
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span class="text-sm" style="color: var(--text-primary)">{ user.Username }</span>
|
||||
</div>
|
||||
<a href="/profile" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Profile
|
||||
</a>
|
||||
if user.Role == "admin" {
|
||||
<a href="/admin" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Admin Panel
|
||||
</a>
|
||||
}
|
||||
<button
|
||||
@click="logout()"
|
||||
type="button"
|
||||
class="block w-full text-left px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
} else {
|
||||
<div class="space-y-3">
|
||||
<form hx-post="/api/auth/login" hx-target="#login-result-mobile" hx-swap="innerHTML" class="space-y-3">
|
||||
<input type="hidden" name="redirect" value="{ currentPath }"/>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<div id="login-result-mobile"></div>
|
||||
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Don't have an account? Sign Up
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Topbar -->
|
||||
<header class="app-topbar">
|
||||
<div class="flex items-center gap-3 px-4 sm:px-6 h-full">
|
||||
<button
|
||||
type="button"
|
||||
class="app-mobile-only icon-btn"
|
||||
@click="mobileMenuOpen = true"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
@Icon("menu", "h-5 w-5")
|
||||
</button>
|
||||
<div class="relative flex-1 max-w-xl">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary);">
|
||||
@Icon("search", "h-4 w-4")
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="header-search"
|
||||
placeholder="Search your library…"
|
||||
class="input pl-10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
</div>
|
||||
}
|
||||
|
||||
// SidebarUserMenu is the bottom-of-sidebar account control for signed-in users.
|
||||
templ SidebarUserMenu(user User) {
|
||||
<div x-data="{ userOpen: false }" class="sidebar-panel">
|
||||
<button
|
||||
type="button"
|
||||
@click="userOpen = !userOpen"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
</span>
|
||||
<span class="flex-1 text-left truncate">{ user.Username }</span>
|
||||
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform")
|
||||
</button>
|
||||
<div x-show="userOpen" x-cloak x-transition class="mt-1 space-y-0.5 pl-1">
|
||||
<a href="/profile" class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover" style="color: var(--text-secondary);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
@click="logout()"
|
||||
class="w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
@Icon("logout", "h-4 w-4")
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
</div>
|
||||
}
|
||||
|
||||
// SidebarSignIn is an inline sign-in for signed-out visitors, preserving the
|
||||
// original header's inline login (htmx POST) so users can auth from any page.
|
||||
templ SidebarSignIn(currentPath string) {
|
||||
<div x-data="{ signInOpen: false }" class="sidebar-panel">
|
||||
<button
|
||||
type="button"
|
||||
@click="signInOpen = !signInOpen"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="grid place-items-center h-7 w-7 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
</span>
|
||||
<span class="flex-1 text-left">Sign in</span>
|
||||
</button>
|
||||
<div x-show="signInOpen" x-cloak x-transition class="mt-1 px-1">
|
||||
<form
|
||||
hx-post="/api/auth/login"
|
||||
hx-target="#login-result"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-2"
|
||||
>
|
||||
<input type="hidden" name="redirect" value={ currentPath }/>
|
||||
<input type="text" name="login" class="input py-1.5 text-sm" placeholder="Email or username" required/>
|
||||
<input type="password" name="password" class="input py-1.5 text-sm" placeholder="Password" required/>
|
||||
<button type="submit" class="btn btn-primary w-full py-1.5 text-sm">Sign In</button>
|
||||
</form>
|
||||
<div id="login-result"></div>
|
||||
<a href="/register" class="block text-center text-xs mt-2 hover:underline" style="color: var(--text-secondary);">
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+608
-43
File diff suppressed because one or more lines are too long
@@ -0,0 +1,175 @@
|
||||
package templates
|
||||
|
||||
// Icon renders an inline SVG from the built-in icon set. All icons share the
|
||||
// same 24x24 viewBox, 1.75 stroke width, and round line caps so the UI has one
|
||||
// consistent visual language (replacing the mixed emoji/SVG iconography).
|
||||
// `extra` is appended to the class list (e.g. "h-5 w-5").
|
||||
templ Icon(name string, extra string) {
|
||||
<svg class={ "reader-icon " + extra } width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
switch name {
|
||||
case "search":
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<path d="m20 20-3.5-3.5"></path>
|
||||
case "user":
|
||||
<circle cx="12" cy="8" r="4"></circle>
|
||||
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"></path>
|
||||
case "users":
|
||||
<circle cx="9" cy="8" r="3.5"></circle>
|
||||
<path d="M2 20c0-3.5 3-5 7-5s7 1.5 7 5"></path>
|
||||
<path d="M16 4.5a3.5 3.5 0 0 1 0 7"></path>
|
||||
<path d="M22 20c0-3-2-4.5-5-4.5"></path>
|
||||
case "palette":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<circle cx="8" cy="10" r="1"></circle>
|
||||
<circle cx="12" cy="7.5" r="1"></circle>
|
||||
<circle cx="15.5" cy="10" r="1"></circle>
|
||||
<path d="M12 21a3 3 0 0 0 0-6 2 2 0 0 1 0-4h1"></path>
|
||||
case "menu":
|
||||
<path d="M3 6h18M3 12h18M3 18h18"></path>
|
||||
case "chevron-down":
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
case "chevron-up":
|
||||
<path d="m6 15 6-6 6 6"></path>
|
||||
case "chevron-left":
|
||||
<path d="m15 6-6 6 6 6"></path>
|
||||
case "chevron-right":
|
||||
<path d="m9 6 6 6-6 6"></path>
|
||||
case "chevrons-left":
|
||||
<path d="m11 6-6 6 6 6M19 6l-6 6 6 6"></path>
|
||||
case "chevrons-right":
|
||||
<path d="m13 6 6 6-6 6M5 6l6 6-6 6"></path>
|
||||
case "close":
|
||||
<path d="M6 6l12 12M18 6 6 18"></path>
|
||||
case "grip":
|
||||
<circle cx="9" cy="6" r="1"></circle>
|
||||
<circle cx="15" cy="6" r="1"></circle>
|
||||
<circle cx="9" cy="12" r="1"></circle>
|
||||
<circle cx="15" cy="12" r="1"></circle>
|
||||
<circle cx="9" cy="18" r="1"></circle>
|
||||
<circle cx="15" cy="18" r="1"></circle>
|
||||
case "settings":
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"></path>
|
||||
case "gear":
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
case "refresh":
|
||||
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
|
||||
<path d="M21 3v5h-5"></path>
|
||||
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
|
||||
<path d="M3 21v-5h5"></path>
|
||||
case "book":
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
|
||||
case "book-open":
|
||||
<path d="M12 7v14"></path>
|
||||
<path d="M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z"></path>
|
||||
<path d="M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z"></path>
|
||||
case "library":
|
||||
<path d="M3 21h18"></path>
|
||||
<path d="M5 21V7l7-4 7 4v14"></path>
|
||||
case "layers":
|
||||
<path d="m12 3 9 5-9 5-9-5 9-5z"></path>
|
||||
<path d="m3 13 9 5 9-5"></path>
|
||||
case "folder":
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
case "chart":
|
||||
<path d="M3 3v18h18"></path>
|
||||
<path d="M7 15v3M12 11v7M17 7v11"></path>
|
||||
case "device":
|
||||
<rect x="7" y="2" width="10" height="20" rx="2"></rect>
|
||||
<path d="M11 18h2"></path>
|
||||
case "trash":
|
||||
<path d="M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13"></path>
|
||||
case "save":
|
||||
<path d="M5 3h11l3 3v15H5z"></path>
|
||||
<path d="M8 3v6h7V3"></path>
|
||||
<path d="M8 14h8v7H8z"></path>
|
||||
case "filter":
|
||||
<path d="M3 4h18l-7 8v6l-4 2v-8z"></path>
|
||||
case "check":
|
||||
<path d="m4 12 5 5 11-11"></path>
|
||||
case "check-circle":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="m8 12 3 3 5-6"></path>
|
||||
case "x-circle":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="m9 9 6 6M15 9l-6 6"></path>
|
||||
case "circle":
|
||||
<circle cx="12" cy="12" r="8"></circle>
|
||||
case "bookmark":
|
||||
<path d="M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z"></path>
|
||||
case "star":
|
||||
<path d="m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z"></path>
|
||||
case "arrow-right":
|
||||
<path d="M5 12h14M13 6l6 6-6 6"></path>
|
||||
case "arrow-left":
|
||||
<path d="M19 12H5M11 6l-6 6 6 6"></path>
|
||||
case "home":
|
||||
<path d="M3 11 12 3l9 8"></path>
|
||||
<path d="M5 10v10h14V10"></path>
|
||||
case "logout":
|
||||
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"></path>
|
||||
<path d="M10 17l5-5-5-5"></path>
|
||||
<path d="M15 12H3"></path>
|
||||
case "plus":
|
||||
<path d="M12 5v14M5 12h14"></path>
|
||||
case "edit":
|
||||
<path d="M4 20h4L18 10l-4-4L4 16z"></path>
|
||||
<path d="m14 6 4 4"></path>
|
||||
case "info":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 11v5M12 8h.01"></path>
|
||||
case "alert":
|
||||
<path d="M12 3 2 20h20z"></path>
|
||||
<path d="M12 10v4M12 17h.01"></path>
|
||||
case "clock":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 7v5l3 2"></path>
|
||||
case "sync":
|
||||
<path d="M3 8a9 9 0 0 1 15-3l3 3"></path>
|
||||
<path d="M21 16a9 9 0 0 1-15 3l-3-3"></path>
|
||||
case "download":
|
||||
<path d="M12 3v12M7 10l5 5 5-5"></path>
|
||||
<path d="M5 21h14"></path>
|
||||
case "upload":
|
||||
<path d="M12 21V9M7 14l5-5 5 5"></path>
|
||||
<path d="M5 3h14"></path>
|
||||
case "copy":
|
||||
<rect x="9" y="9" width="12" height="12" rx="2"></rect>
|
||||
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"></path>
|
||||
case "external":
|
||||
<path d="M14 4h6v6"></path>
|
||||
<path d="M20 4 10 14"></path>
|
||||
<path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"></path>
|
||||
case "list":
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"></path>
|
||||
case "grid":
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"></rect>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"></rect>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"></rect>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"></rect>
|
||||
case "play":
|
||||
<path d="M6 4l14 8-14 8z"></path>
|
||||
case "heart":
|
||||
<path d="M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z"></path>
|
||||
case "calendar":
|
||||
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
|
||||
<path d="M3 9h18M8 2v4M16 2v4"></path>
|
||||
case "tag":
|
||||
<path d="M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z"></path>
|
||||
<circle cx="7.5" cy="7.5" r="1"></circle>
|
||||
case "globe":
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"></path>
|
||||
case "shield":
|
||||
<path d="M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z"></path>
|
||||
case "database":
|
||||
<ellipse cx="12" cy="5" rx="8" ry="3"></ellipse>
|
||||
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"></path>
|
||||
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path>
|
||||
default:
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
}
|
||||
</svg>
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
// Icon renders an inline SVG from the built-in icon set. All icons share the
|
||||
// same 24x24 viewBox, 1.75 stroke width, and round line caps so the UI has one
|
||||
// consistent visual language (replacing the mixed emoji/SVG iconography).
|
||||
// `extra` is appended to the class list (e.g. "h-5 w-5").
|
||||
func Icon(name string, extra string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var2 = []any{"reader-icon " + extra}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/icons.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
switch name {
|
||||
case "search":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<circle cx=\"11\" cy=\"11\" r=\"7\"></circle> <path d=\"m20 20-3.5-3.5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "user":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<circle cx=\"12\" cy=\"8\" r=\"4\"></circle> <path d=\"M4 20c0-4 4-6 8-6s8 2 8 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "users":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<circle cx=\"9\" cy=\"8\" r=\"3.5\"></circle> <path d=\"M2 20c0-3.5 3-5 7-5s7 1.5 7 5\"></path> <path d=\"M16 4.5a3.5 3.5 0 0 1 0 7\"></path> <path d=\"M22 20c0-3-2-4.5-5-4.5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "palette":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <circle cx=\"8\" cy=\"10\" r=\"1\"></circle> <circle cx=\"12\" cy=\"7.5\" r=\"1\"></circle> <circle cx=\"15.5\" cy=\"10\" r=\"1\"></circle> <path d=\"M12 21a3 3 0 0 0 0-6 2 2 0 0 1 0-4h1\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "menu":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<path d=\"M3 6h18M3 12h18M3 18h18\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevron-down":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<path d=\"m6 9 6 6 6-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevron-up":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<path d=\"m6 15 6-6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevron-left":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<path d=\"m15 6-6 6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevron-right":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<path d=\"m9 6 6 6-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevrons-left":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<path d=\"m11 6-6 6 6 6M19 6l-6 6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chevrons-right":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<path d=\"m13 6 6 6-6 6M5 6l6 6-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "close":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<path d=\"M6 6l12 12M18 6 6 18\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "grip":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<circle cx=\"9\" cy=\"6\" r=\"1\"></circle> <circle cx=\"15\" cy=\"6\" r=\"1\"></circle> <circle cx=\"9\" cy=\"12\" r=\"1\"></circle> <circle cx=\"15\" cy=\"12\" r=\"1\"></circle> <circle cx=\"9\" cy=\"18\" r=\"1\"></circle> <circle cx=\"15\" cy=\"18\" r=\"1\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "settings":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "gear":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle> <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "refresh":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<path d=\"M3 12a9 9 0 0 1 15-6.7L21 8\"></path> <path d=\"M21 3v5h-5\"></path> <path d=\"M21 12a9 9 0 0 1-15 6.7L3 16\"></path> <path d=\"M3 21v-5h5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "book":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"></path> <path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "book-open":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<path d=\"M12 7v14\"></path> <path d=\"M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z\"></path> <path d=\"M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "library":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<path d=\"M3 21h18\"></path> <path d=\"M5 21V7l7-4 7 4v14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "layers":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<path d=\"m12 3 9 5-9 5-9-5 9-5z\"></path> <path d=\"m3 13 9 5 9-5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "folder":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "chart":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<path d=\"M3 3v18h18\"></path> <path d=\"M7 15v3M12 11v7M17 7v11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "device":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<rect x=\"7\" y=\"2\" width=\"10\" height=\"20\" rx=\"2\"></rect> <path d=\"M11 18h2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "trash":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<path d=\"M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "save":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<path d=\"M5 3h11l3 3v15H5z\"></path> <path d=\"M8 3v6h7V3\"></path> <path d=\"M8 14h8v7H8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "filter":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<path d=\"M3 4h18l-7 8v6l-4 2v-8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "check":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<path d=\"m4 12 5 5 11-11\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "check-circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m8 12 3 3 5-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "x-circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"m9 9 6 6M15 9l-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "circle":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<circle cx=\"12\" cy=\"12\" r=\"8\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "bookmark":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<path d=\"M7 3h10a1 1 0 0 1 1 1v17l-6-4-6 4V4a1 1 0 0 1 1-1z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "star":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<path d=\"m12 3 2.7 5.5 6 .9-4.3 4.2 1 6L12 17l-5.4 2.6 1-6L3.3 9.4l6-.9z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "arrow-right":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<path d=\"M5 12h14M13 6l6 6-6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "arrow-left":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<path d=\"M19 12H5M11 6l-6 6 6 6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "home":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<path d=\"M3 11 12 3l9 8\"></path> <path d=\"M5 10v10h14V10\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "logout":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<path d=\"M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3\"></path> <path d=\"M10 17l5-5-5-5\"></path> <path d=\"M15 12H3\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "plus":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<path d=\"M12 5v14M5 12h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "edit":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<path d=\"M4 20h4L18 10l-4-4L4 16z\"></path> <path d=\"m14 6 4 4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "info":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 11v5M12 8h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "alert":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<path d=\"M12 3 2 20h20z\"></path> <path d=\"M12 10v4M12 17h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "clock":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M12 7v5l3 2\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "sync":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<path d=\"M3 8a9 9 0 0 1 15-3l3 3\"></path> <path d=\"M21 16a9 9 0 0 1-15 3l-3-3\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "download":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<path d=\"M12 3v12M7 10l5 5 5-5\"></path> <path d=\"M5 21h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "upload":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<path d=\"M12 21V9M7 14l5-5 5 5\"></path> <path d=\"M5 3h14\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "copy":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2\"></rect> <path d=\"M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "external":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<path d=\"M14 4h6v6\"></path> <path d=\"M20 4 10 14\"></path> <path d=\"M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "list":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<path d=\"M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "grid":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect> <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"></rect>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "play":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<path d=\"M6 4l14 8-14 8z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "heart":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<path d=\"M12 21s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "calendar":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"></rect> <path d=\"M3 9h18M8 2v4M16 2v4\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "tag":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<path d=\"M3 11V5a2 2 0 0 1 2-2h6l9 9-8 8-9-9z\"></path> <circle cx=\"7.5\" cy=\"7.5\" r=\"1\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "globe":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle> <path d=\"M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "shield":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<path d=\"M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6z\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case "database":
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<ellipse cx=\"12\" cy=\"5\" rx=\"8\" ry=\"3\"></ellipse> <path d=\"M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5\"></path> <path d=\"M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6\"></path>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
default:
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<circle cx=\"12\" cy=\"12\" r=\"9\"></circle>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</svg>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
+72
-83
@@ -10,110 +10,99 @@ templ Index(loggedIn bool) {
|
||||
<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()">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10"></div>
|
||||
<div class="relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
|
||||
<div class="flex justify-between items-start mb-8">
|
||||
<div></div>
|
||||
<select id="theme-select" @change="changeTheme" class="px-3 py-2 border rounded text-sm" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="tokyo-night">Tokyo Night</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="solarized-dark">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="one-dark-pro">One Dark Pro</option>
|
||||
<option value="material-dark">Material Dark</option>
|
||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
</select>
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<select
|
||||
id="theme-select"
|
||||
@change="changeTheme"
|
||||
class="input w-auto py-1.5 pr-8 text-sm cursor-pointer"
|
||||
aria-label="Theme"
|
||||
>
|
||||
for _, opt := range ThemeOptions {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Hero -->
|
||||
<div class="brand-gradient relative overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto px-4 py-20 sm:px-6 sm:py-28 lg:py-36 lg:px-8 text-center">
|
||||
<div class="inline-flex items-center gap-3 mb-6">
|
||||
<span class="text-5xl">📚</span>
|
||||
<span class="text-4xl sm:text-6xl font-extrabold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl sm:text-6xl lg:text-7xl font-extrabold" style="color: var(--text-primary)">
|
||||
📚 Bookhoard
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl mx-auto text-xl" style="color: var(--text-secondary)">
|
||||
Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.
|
||||
</p>
|
||||
<div class="mt-10">
|
||||
<a href="#auth" class="btn-primary px-8 py-3 rounded-lg font-medium text-lg">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-4 max-w-2xl mx-auto text-lg sm:text-xl" style="color: var(--text-secondary)">
|
||||
Your personal ebook management system. Track reading progress, organize your library, and sync across every device.
|
||||
</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-3">
|
||||
if loggedIn {
|
||||
<a href="/dashboard" class="btn btn-primary px-8 py-3 text-base">Go to Library</a>
|
||||
} else {
|
||||
<a href="#auth" class="btn btn-primary px-8 py-3 text-base">Get Started</a>
|
||||
<a href="#features" class="btn btn-secondary px-6 py-3 text-base">Learn more</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Features Section -->
|
||||
<div class="py-16" style="background-color: var(--bg-secondary)">
|
||||
<!-- Features -->
|
||||
<div id="features" class="py-16 sm:py-20">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Why Bookhoard?</h2>
|
||||
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Discover the features that make ebook management effortless</p>
|
||||
<h2 class="text-3xl font-extrabold tracking-tight" style="color: var(--text-primary)">Why Bookhoard?</h2>
|
||||
<p class="mt-3 text-lg" style="color: var(--text-secondary)">Everything you need to manage your ebook library</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4">
|
||||
<span class="text-white text-2xl">📖</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
|
||||
<p style="color: var(--text-secondary)">Track your reading progress across all your ebooks with detailed statistics.</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="card p-6">
|
||||
<div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("book-open", "h-6 w-6")
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Reading Progress</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Track progress across all your ebooks with detailed statistics and sync.</p>
|
||||
</div>
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4">
|
||||
<span class="text-white text-2xl">🎨</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
|
||||
<p style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p>
|
||||
<div class="card p-6">
|
||||
<div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("palette", "h-6 w-6")
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Beautiful Themes</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin.</p>
|
||||
</div>
|
||||
<div class="card p-6 rounded-xl border">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4">
|
||||
<span class="text-white text-2xl">⚡</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Fast & Modern</h3>
|
||||
<p style="color: var(--text-secondary)">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p>
|
||||
<div class="card p-6">
|
||||
<div class="grid place-items-center h-12 w-12 rounded-xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("device", "h-6 w-6")
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">Cross-Device Sync</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Built with Go and HTMX for fast performance and seamless device syncing.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Auth Section -->
|
||||
<!-- Auth -->
|
||||
if !loggedIn {
|
||||
<div id="auth" class="py-16">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-extrabold" style="color: var(--text-primary)">Join Bookhoard Today</h2>
|
||||
<p class="mt-4 text-lg" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
|
||||
<div id="auth" class="pb-20 px-4">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-extrabold tracking-tight" style="color: var(--text-primary)">Welcome back</h2>
|
||||
<p class="mt-2" style="color: var(--text-secondary)">Sign in to start managing your ebook library</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-8">
|
||||
<div class="card p-8 rounded-xl border">
|
||||
<h3 class="text-2xl font-semibold mb-6 text-center" style="color: var(--text-primary)">Login</h3>
|
||||
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded-lg" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full btn-primary py-2 rounded-lg font-medium">
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card p-8">
|
||||
<form hx-post="/api/auth/login" hx-target="#auth-result" hx-swap="innerHTML" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="input" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="input" required/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full py-2.5">Sign In</button>
|
||||
</form>
|
||||
<p class="text-center text-sm mt-4" style="color: var(--text-secondary)">
|
||||
No account?
|
||||
<a href="/register" class="font-medium hover:underline" style="color: var(--accent)">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
<div id="auth-result" class="mt-8 text-center"></div>
|
||||
<div id="auth-result" class="mt-4 text-center text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -29,17 +29,92 @@ func Index(loggedIn bool) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Bookhoard - Home</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night\" x-data=\"index\" x-init=\"initIndexTheme(); checkAuthRedirect()\"><!-- Hero Section --><div class=\"relative overflow-hidden\"><div class=\"absolute inset-0 bg-gradient-to-r from-blue-600 to-purple-700 opacity-10\"></div><div class=\"relative max-w-7xl mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8\"><div class=\"flex justify-between items-start mb-8\"><div></div><select id=\"theme-select\" @change=\"changeTheme\" class=\"px-3 py-2 border rounded text-sm\" style=\"background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)\"><option value=\"tokyo-night\">Tokyo Night</option> <option value=\"dracula\">Dracula</option> <option value=\"nord\">Nord</option> <option value=\"solarized-dark\">Solarized Dark</option> <option value=\"monokai\">Monokai</option> <option value=\"one-dark-pro\">One Dark Pro</option> <option value=\"material-dark\">Material Dark</option> <option value=\"catppuccin-mocha\">Catppuccin Mocha</option> <option value=\"catppuccin-macchiato\">Catppuccin Macchiato</option> <option value=\"catppuccin-frappe\">Catppuccin Frappé</option> <option value=\"catppuccin-latte\">Catppuccin Latte</option></select></div><div class=\"text-center\"><h1 class=\"text-4xl sm:text-6xl lg:text-7xl font-extrabold\" style=\"color: var(--text-primary)\">📚 Bookhoard</h1><p class=\"mt-6 max-w-2xl mx-auto text-xl\" style=\"color: var(--text-secondary)\">Your personal ebook management system. Track reading progress, organize your library, and enjoy beautiful themes.</p><div class=\"mt-10\"><a href=\"#auth\" class=\"btn-primary px-8 py-3 rounded-lg font-medium text-lg\">Get Started</a></div></div></div></div><!-- Features Section --><div class=\"py-16\" style=\"background-color: var(--bg-secondary)\"><div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"text-center mb-12\"><h2 class=\"text-3xl font-extrabold\" style=\"color: var(--text-primary)\">Why Bookhoard?</h2><p class=\"mt-4 text-lg\" style=\"color: var(--text-secondary)\">Discover the features that make ebook management effortless</p></div><div class=\"grid grid-cols-1 md:grid-cols-3 gap-8\"><div class=\"card p-6 rounded-xl border\"><div class=\"text-center\"><div class=\"mx-auto w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center mb-4\"><span class=\"text-white text-2xl\">📖</span></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Reading Progress</h3><p style=\"color: var(--text-secondary)\">Track your reading progress across all your ebooks with detailed statistics.</p></div></div><div class=\"card p-6 rounded-xl border\"><div class=\"text-center\"><div class=\"mx-auto w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center mb-4\"><span class=\"text-white text-2xl\">🎨</span></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Beautiful Themes</h3><p style=\"color: var(--text-secondary)\">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin variants.</p></div></div><div class=\"card p-6 rounded-xl border\"><div class=\"text-center\"><div class=\"mx-auto w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center mb-4\"><span class=\"text-white text-2xl\">⚡</span></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Fast & Modern</h3><p style=\"color: var(--text-secondary)\">Built with Go and HTMX for lightning-fast performance and smooth interactions.</p></div></div></div></div></div><!-- Auth Section -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Bookhoard - Home</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !loggedIn {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div id=\"auth\" class=\"py-16\"><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"text-center mb-12\"><h2 class=\"text-3xl font-extrabold\" style=\"color: var(--text-primary)\">Join Bookhoard Today</h2><p class=\"mt-4 text-lg\" style=\"color: var(--text-secondary)\">Sign in to start managing your ebook library</p></div><div class=\"grid grid-cols-1 gap-8\"><div class=\"card p-8 rounded-xl border\"><h3 class=\"text-2xl font-semibold mb-6 text-center\" style=\"color: var(--text-primary)\">Login</h3><form hx-post=\"/api/auth/login\" hx-target=\"#auth-result\" hx-swap=\"innerHTML\"><div class=\"space-y-4\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"w-full px-3 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><button type=\"submit\" class=\"w-full btn-primary py-2 rounded-lg font-medium\">Sign In</button></div></form></div></div><div id=\"auth-result\" class=\"mt-8 text-center\"></div></div></div>")
|
||||
for _, opt := range ThemeOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</select></div><!-- Hero --><div class=\"brand-gradient relative overflow-hidden\"><div class=\"max-w-7xl mx-auto px-4 py-20 sm:px-6 sm:py-28 lg:py-36 lg:px-8 text-center\"><div class=\"inline-flex items-center gap-3 mb-6\"><span class=\"text-5xl\">📚</span> <span class=\"text-4xl sm:text-6xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></div><p class=\"mt-4 max-w-2xl mx-auto text-lg sm:text-xl\" style=\"color: var(--text-secondary)\">Your personal ebook management system. Track reading progress, organize your library, and sync across every device.</p><div class=\"mt-10 flex items-center justify-center gap-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if loggedIn {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"/dashboard\" class=\"btn btn-primary px-8 py-3 text-base\">Go to Library</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"#auth\" class=\"btn btn-primary px-8 py-3 text-base\">Get Started</a> <a href=\"#features\" class=\"btn btn-secondary px-6 py-3 text-base\">Learn more</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></div><!-- Features --><div id=\"features\" class=\"py-16 sm:py-20\"><div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"text-center mb-12\"><h2 class=\"text-3xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Why Bookhoard?</h2><p class=\"mt-3 text-lg\" style=\"color: var(--text-secondary)\">Everything you need to manage your ebook library</p></div><div class=\"grid grid-cols-1 md:grid-cols-3 gap-6\"><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("book-open", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Reading Progress</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Track progress across all your ebooks with detailed statistics and sync.</p></div><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("palette", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Beautiful Themes</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Choose from 11 stunning themes including Tokyo Night, Dracula, and Catppuccin.</p></div><div class=\"card p-6\"><div class=\"grid place-items-center h-12 w-12 rounded-xl mb-4\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("device", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">Cross-Device Sync</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Built with Go and HTMX for fast performance and seamless device syncing.</p></div></div></div></div><!-- Auth -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !loggedIn {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div id=\"auth\" class=\"pb-20 px-4\"><div class=\"max-w-md mx-auto\"><div class=\"text-center mb-8\"><h2 class=\"text-2xl font-extrabold tracking-tight\" style=\"color: var(--text-primary)\">Welcome back</h2><p class=\"mt-2\" style=\"color: var(--text-secondary)\">Sign in to start managing your ebook library</p></div><div class=\"card p-8\"><form hx-post=\"/api/auth/login\" hx-target=\"#auth-result\" hx-swap=\"innerHTML\" class=\"space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full py-2.5\">Sign In</button></form><p class=\"text-center text-sm mt-4\" style=\"color: var(--text-secondary)\">No account? <a href=\"/register\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Register</a></p></div><div id=\"auth-result\" class=\"mt-4 text-center text-sm\"></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
package templates
|
||||
|
||||
templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...templ.Component) {
|
||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||
<div class="w-full px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
|
||||
<div
|
||||
class="app-subbar backdrop-blur border-b"
|
||||
style="background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);"
|
||||
>
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library</label>
|
||||
<select
|
||||
id="library-select"
|
||||
name="library_id"
|
||||
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||||
class="input w-auto py-1.5 pr-8 cursor-pointer"
|
||||
>
|
||||
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
||||
for _, lib := range libData {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
||||
for _, lib := range libData {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
}
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
if len(actions) > 0 {
|
||||
@@ -42,18 +44,16 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
|
||||
templ DashboardActions() {
|
||||
<button
|
||||
data-action="open-dashboard-settings"
|
||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-secondary);"
|
||||
class="icon-btn"
|
||||
title="Customize Dashboard"
|
||||
>
|
||||
⚙️
|
||||
@Icon("grid", "h-5 w-5")
|
||||
</button>
|
||||
<button
|
||||
data-action="reload-page"
|
||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
style="background-color: var(--bg-secondary);"
|
||||
class="icon-btn"
|
||||
title="Refresh"
|
||||
>
|
||||
🔄
|
||||
@Icon("refresh", "h-5 w-5")
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -29,14 +29,14 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"app-subbar backdrop-blur border-b\" style=\"background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent); border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);\"><div class=\"w-full px-4 sm:px-6 lg:px-8 py-3 flex items-center justify-between gap-4\"><div class=\"flex items-center gap-3\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"library-select\" name=\"library_id\" class=\"input w-auto py-1.5 pr-8 cursor-pointer\"><option value=\"\">All Libraries (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 14, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -55,7 +55,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -68,7 +68,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -81,7 +81,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -99,7 +99,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -112,7 +112,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -125,7 +125,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -186,7 +186,23 @@ func DashboardActions() templ.Component {
|
||||
templ_7745c5c3_Var9 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("grid", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+46
-48
@@ -6,60 +6,58 @@ templ Login(sessionExpired bool, deleted bool) {
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Login</title>
|
||||
<title>Login - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<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="container mx-auto px-4 py-8 max-w-md">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold">Login to Bookhoard</h1>
|
||||
<select id="theme-select" @change="changeTheme()" class="px-3 py-2 border rounded" style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)">
|
||||
<option value="tokyo-night">Tokyo Night</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="solarized-dark">Solarized Dark</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
<option value="one-dark-pro">One Dark Pro</option>
|
||||
<option value="material-dark">Material Dark</option>
|
||||
<option value="catppuccin-mocha">Catppuccin Mocha</option>
|
||||
<option value="catppuccin-macchiato">Catppuccin Macchiato</option>
|
||||
<option value="catppuccin-frappe">Catppuccin Frappé</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
</select>
|
||||
<div class="brand-gradient min-h-screen flex flex-col items-center justify-center p-4 relative">
|
||||
<select
|
||||
id="theme-select"
|
||||
@change="changeTheme()"
|
||||
class="input absolute top-4 right-4 w-auto py-1.5 pr-8 text-sm cursor-pointer"
|
||||
aria-label="Theme"
|
||||
>
|
||||
for _, opt := range ThemeOptions {
|
||||
<option value={ opt.Name }>{ opt.Label }</option>
|
||||
}
|
||||
</select>
|
||||
<div class="w-full max-w-sm">
|
||||
<a href="/" class="flex items-center justify-center gap-2.5 mb-8">
|
||||
<span class="text-3xl">📚</span>
|
||||
<span class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Bookhoard</span>
|
||||
</a>
|
||||
if sessionExpired {
|
||||
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid var(--accent);">
|
||||
@Icon("info", "h-5 w-5 shrink-0")
|
||||
<p class="text-sm" style="color: var(--text-primary)">Your session has expired. Please log in again to continue.</p>
|
||||
</div>
|
||||
}
|
||||
if deleted {
|
||||
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-success) 14%, transparent); border: 1px solid var(--status-success);">
|
||||
@Icon("check-circle", "h-5 w-5 shrink-0")
|
||||
<p class="text-sm" style="color: var(--text-primary)">Your account has been deleted successfully.</p>
|
||||
</div>
|
||||
}
|
||||
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="input" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="input" required/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full py-2.5">Sign In</button>
|
||||
</form>
|
||||
<div id="result" class="mt-4 text-center text-sm"></div>
|
||||
<p class="text-center mt-6 text-sm" style="color: var(--text-secondary)">
|
||||
Don't have an account?
|
||||
<a href="/register" class="font-medium hover:underline" style="color: var(--accent)">Sign up</a>
|
||||
</p>
|
||||
</div>
|
||||
if sessionExpired {
|
||||
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
|
||||
<p style="color: var(--text-primary);">
|
||||
Your session has expired. Please log in again to continue.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
if deleted {
|
||||
<div class="mb-4 p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--accent);">
|
||||
<p style="color: var(--text-primary);">
|
||||
Your account has been deleted successfully.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<form hx-post="/api/auth/login" hx-target="#result" hx-swap="innerHTML" class="card p-6 rounded-lg shadow-md border">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)" required/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full py-2 rounded">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<div id="result" class="mt-4 text-center"></div>
|
||||
<p class="text-center mt-4">
|
||||
<a href="/register" class="text-blue-500 hover:underline">Don't have an account? Sign Up</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -29,23 +29,75 @@ func Login(sessionExpired bool, deleted bool) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Login</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night min-h-screen\" x-data=\"login\" x-init=\"initLoginTheme()\"><div class=\"container mx-auto px-4 py-8 max-w-md\"><div class=\"flex justify-between items-center mb-8\"><h1 class=\"text-3xl font-bold\">Login to Bookhoard</h1><select id=\"theme-select\" @change=\"changeTheme()\" class=\"px-3 py-2 border rounded\" style=\"background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)\"><option value=\"tokyo-night\">Tokyo Night</option> <option value=\"dracula\">Dracula</option> <option value=\"nord\">Nord</option> <option value=\"solarized-dark\">Solarized Dark</option> <option value=\"monokai\">Monokai</option> <option value=\"one-dark-pro\">One Dark Pro</option> <option value=\"material-dark\">Material Dark</option> <option value=\"catppuccin-mocha\">Catppuccin Mocha</option> <option value=\"catppuccin-macchiato\">Catppuccin Macchiato</option> <option value=\"catppuccin-frappe\">Catppuccin Frappé</option> <option value=\"catppuccin-latte\">Catppuccin Latte</option></select></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Login - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><script type=\"module\" src=\"/static/main.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range ThemeOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/login.templ`, Line: 24, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</select><div class=\"w-full max-w-sm\"><a href=\"/\" class=\"flex items-center justify-center gap-2.5 mb-8\"><span class=\"text-3xl\">📚</span> <span class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Bookhoard</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if sessionExpired {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"mb-4 p-3 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--accent);\"><p style=\"color: var(--text-primary);\">Your session has expired. Please log in again to continue.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"mb-4 p-3 rounded-xl flex items-start gap-2\" style=\"background-color: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your session has expired. Please log in again to continue.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if deleted {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"mb-4 p-3 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--accent);\"><p style=\"color: var(--text-primary);\">Your account has been deleted successfully.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"mb-4 p-3 rounded-xl flex items-start gap-2\" style=\"background-color: color-mix(in srgb, var(--status-success) 14%, transparent); border: 1px solid var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("check-circle", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"text-sm\" style=\"color: var(--text-primary)\">Your account has been deleted successfully.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 rounded-lg shadow-md border\"><div class=\"mb-4\"><label class=\"block mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><button type=\"submit\" class=\"btn-primary w-full py-2 rounded\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center\"></div><p class=\"text-center mt-4\"><a href=\"/register\" class=\"text-blue-500 hover:underline\">Don't have an account? Sign Up</a></p></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 space-y-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"input\" required></div><button type=\"submit\" class=\"btn btn-primary w-full py-2.5\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center text-sm\"></div><p class=\"text-center mt-6 text-sm\" style=\"color: var(--text-secondary)\">Don't have an account? <a href=\"/register\" class=\"font-medium hover:underline\" style=\"color: var(--accent)\">Sign up</a></p></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+24
-12
@@ -8,24 +8,36 @@ templ Profile(user User) {
|
||||
<title>Profile 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 }" x-data="profile">
|
||||
@Header(user, "/profile")
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Profile Settings</h1>
|
||||
<p style="color: var(--text-secondary)">Manage your account information and preferences</p>
|
||||
<main class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Profile Settings</h1>
|
||||
<p class="text-sm mt-0.5" style="color: var(--text-secondary)">Manage your account information and preferences</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-8">
|
||||
<!-- Account Information & Password - uses reusable ProfileForm -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="space-y-6">
|
||||
<div class="card p-6">
|
||||
@ProfileForm(user, "/api/auth/profile", true, false, false)
|
||||
</div>
|
||||
<!-- Danger Zone -->
|
||||
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h3 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Danger Zone</h3>
|
||||
<p style="color: var(--text-secondary)" class="mb-4">Once you delete your account, there is no going back. Please be certain.</p>
|
||||
<button @click="confirmDeleteAccount()" class="px-4 py-2 rounded text-sm" style="background-color: #dc2626; color: white;">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: color-mix(in srgb, var(--status-danger) 18%, transparent); color: var(--status-danger);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Danger Zone</h3>
|
||||
<p class="text-sm mt-0.5" style="color: var(--text-secondary)">Once you delete your account, there is no going back. Please be certain.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="confirmDeleteAccount()" class="btn btn-danger">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Remove My Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,60 +6,57 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
||||
hx-target="#profile-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
|
||||
class="space-y-6"
|
||||
class="space-y-8"
|
||||
>
|
||||
<!-- Account Information -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Account Information</h3>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
@Icon("user", "h-5 w-5")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Account Information</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={ user.Username }
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email Address</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={ user.Email }
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="first_name"
|
||||
value={ user.FirstName }
|
||||
placeholder="Optional"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="last_name"
|
||||
value={ user.LastName }
|
||||
placeholder="Optional"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Theme</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Theme</label>
|
||||
<select
|
||||
name="theme"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="tokyo-night" selected?={ user.Theme == "tokyo-night" }>Tokyo Night</option>
|
||||
<option value="dracula" selected?={ user.Theme == "dracula" }>Dracula</option>
|
||||
@@ -71,11 +68,10 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Timezone</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
|
||||
<select
|
||||
name="timezone"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (UTC+0)</option>
|
||||
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
|
||||
@@ -105,11 +101,10 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
||||
</div>
|
||||
if showRoleField {
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Role</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Role</label>
|
||||
<select
|
||||
name="role"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
>
|
||||
<option value="user" selected?={ user.Role == "user" }>User</option>
|
||||
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
||||
@@ -118,39 +113,37 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Change Password Section -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4" style="color: var(--text-primary)">Change Password</h3>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
@Icon("shield", "h-5 w-5")
|
||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Change Password</h3>
|
||||
</div>
|
||||
if requireCurrentPassword {
|
||||
<!-- Self-edit: require current password -->
|
||||
<div class="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Current Password</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="current_password"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
minlength="6"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
minlength="6"
|
||||
/>
|
||||
</div>
|
||||
@@ -161,36 +154,33 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
||||
hx-target="#password-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="closest form"
|
||||
class="px-4 py-2 rounded"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
Update Password
|
||||
</button>
|
||||
<div id="password-result" class="mt-2"></div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Admin edit: no current password required -->
|
||||
<p class="text-sm italic mb-4" style="color: var(--text-secondary);">
|
||||
As an admin, you can change this user's password without knowing their current password.
|
||||
</p>
|
||||
<div class="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">New Password</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
minlength="6"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm New Password</label>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
class="input"
|
||||
minlength="6"
|
||||
/>
|
||||
</div>
|
||||
@@ -201,32 +191,30 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
||||
hx-target="#password-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="closest form"
|
||||
class="px-4 py-2 rounded"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
Reset Password
|
||||
</button>
|
||||
<div id="password-result" class="mt-2"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-6 border-t" style="border-color: var(--border);">
|
||||
if showCancelButton {
|
||||
<button
|
||||
type="button"
|
||||
@click="closeProfileModal()"
|
||||
class="px-4 py-2 rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+123
-83
@@ -42,33 +42,41 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" hx-target=\"#profile-result\" hx-swap=\"innerHTML\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' class=\"space-y-6\"><!-- Account Information --><div><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Account Information</h3><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Username</label> <input type=\"text\" name=\"username\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" hx-target=\"#profile-result\" hx-swap=\"innerHTML\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' class=\"space-y-8\"><div><div class=\"flex items-center gap-2 mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("user", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Account Information</h3></div><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Username</label> <input type=\"text\" name=\"username\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 20, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 22, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Email Address</label> <input type=\"email\" name=\"email\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Email Address</label> <input type=\"email\" name=\"email\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 31, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">First Name</label> <input type=\"text\" name=\"first_name\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">First Name</label> <input type=\"text\" name=\"first_name\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -81,398 +89,430 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" placeholder=\"Optional\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Last Name</label> <input type=\"text\" name=\"last_name\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"Optional\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Last Name</label> <input type=\"text\" name=\"last_name\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 51, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 50, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" placeholder=\"Optional\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Theme</label> <select name=\"theme\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"tokyo-night\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" placeholder=\"Optional\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Theme</label> <select name=\"theme\" class=\"input\"><option value=\"tokyo-night\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Theme == "tokyo-night" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ">Tokyo Night</option> <option value=\"dracula\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">Tokyo Night</option> <option value=\"dracula\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Theme == "dracula" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">Dracula</option> <option value=\"nord\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Dracula</option> <option value=\"nord\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Theme == "nord" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ">Nord</option> <option value=\"solarized-dark\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Nord</option> <option value=\"solarized-dark\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Theme == "solarized-dark" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Solarized Dark</option> <option value=\"monokai\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">Solarized Dark</option> <option value=\"monokai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Theme == "monokai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ">Monokai</option> <option value=\"one-dark-pro\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, ">Monokai</option> <option value=\"one-dark-pro\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Theme == "one-dark-pro" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ">One Dark Pro</option> <option value=\"material-dark\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">One Dark Pro</option> <option value=\"material-dark\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Theme == "material-dark" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, ">Material Dark</option></select></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"UTC\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "UTC" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Pacific/Honolulu" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Anchorage" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Los_Angeles" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Denver" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Phoenix" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Chicago" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/New_York" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "America/Sao_Paulo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Europe/London" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Europe/Paris" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Europe/Helsinki" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Europe/Moscow" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Tehran" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Dubai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Karachi" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Kolkata" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Dhaka" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Bangkok" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Shanghai" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Asia/Tokyo" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Australia/Darwin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Australia/Sydney" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Timezone == "Pacific/Auckland" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, ">New Zealand (UTC+12/+13)</option></select></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">New Zealand (UTC+12/+13)</option></select></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if showRoleField {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"user\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "user" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">User</option> <option value=\"admin\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">User</option> <option value=\"admin\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Admin</option></select></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">Admin</option></select></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div></div><!-- Change Password Section --><div><h3 class=\"text-lg font-semibold mb-4\" style=\"color: var(--text-primary)\">Change Password</h3>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div><div><div class=\"flex items-center gap-2 mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("shield", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if requireCurrentPassword {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<!-- Self-edit: require current password --> <div class=\"space-y-4 max-w-md\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" required></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" minlength=\"6\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" name=\"confirm_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" minlength=\"6\"></div><button type=\"button\" hx-put=\"/api/auth/password\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"px-4 py-2 rounded\" style=\"background-color: var(--accent); color: white;\">Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"input\" minlength=\"6\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" name=\"confirm_password\" class=\"input\" minlength=\"6\"></div><button type=\"button\" hx-put=\"/api/auth/password\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<!-- Admin edit: no current password required --> <p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" minlength=\"6\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" name=\"confirm_password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" minlength=\"6\"></div><button type=\"button\" hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" name=\"new_password\" class=\"input\" minlength=\"6\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" name=\"confirm_password\" class=\"input\" minlength=\"6\"></div><button type=\"button\" hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 199, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 189, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"px-4 py-2 rounded\" style=\"background-color: var(--accent); color: white;\">Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-headers='{\"Authorization\": \"Bearer \" + localStorage.getItem(\"token\")}' hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</div><!-- Actions --><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if showCancelButton {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"px-4 py-2 rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\">Cancel</button> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<button type=\"submit\" class=\"px-4 py-2 rounded\" style=\"background-color: var(--accent); color: white;\">Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<button type=\"submit\" class=\"btn btn-primary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
package templates
|
||||
|
||||
templ ProfileModal(user User) {
|
||||
<div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50" x-data="profileModal" x-init="setupProfileModal()" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" style="background-color: var(--bg-secondary);">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex justify-between items-center p-6 border-b" style="border-color: var(--border);">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Edit User Profile</h2>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary);">
|
||||
{ user.Username } ({ user.Email })
|
||||
</p>
|
||||
<div id="profile-modal" class="fixed inset-0 flex items-center justify-center z-50 p-4" x-data="profileModal" x-init="setupProfileModal()" style="background-color: var(--surface-overlay);">
|
||||
<div class="card w-full max-w-4xl max-h-[90vh] overflow-y-auto" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-start gap-4 p-6 border-b" style="border-color: var(--border);">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("edit", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit User Profile</h2>
|
||||
<p class="text-sm mt-0.5" style="color: var(--text-secondary);">
|
||||
{ user.Username } ({ user.Email })
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="closeProfileModal()"
|
||||
class="text-2xl"
|
||||
style="color: var(--text-secondary);"
|
||||
class="icon-btn shrink-0"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body - uses reusable form -->
|
||||
<div class="p-6">
|
||||
@ProfileForm(user, "/api/auth/profile/" + user.ID, false, true, true)
|
||||
</div>
|
||||
|
||||
@@ -29,33 +29,49 @@ func ProfileModal(user User) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"profile-modal\" class=\"fixed inset-0 flex items-center justify-center z-50\" x-data=\"profileModal\" x-init=\"setupProfileModal()\" style=\"background-color: rgba(0,0,0,0.5);\"><div class=\"rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto\" style=\"background-color: var(--bg-secondary);\"><!-- Modal Header --><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><div><h2 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">Edit User Profile</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"profile-modal\" class=\"fixed inset-0 flex items-center justify-center z-50 p-4\" x-data=\"profileModal\" x-init=\"setupProfileModal()\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-4xl max-h-[90vh] overflow-y-auto\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-start gap-4 p-6 border-b\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("edit", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Edit User Profile</h2><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 11, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 14, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 11, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_modal.templ`, Line: 14, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, ")</p></div><button @click=\"closeProfileModal()\" class=\"text-2xl\" style=\"color: var(--text-secondary);\">×</button></div><!-- Modal Body - uses reusable form --><div class=\"p-6\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ")</p></div></div><button @click=\"closeProfileModal()\" class=\"icon-btn shrink-0\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</button></div><div class=\"p-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -63,7 +79,7 @@ func ProfileModal(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func Profile(user User) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Profile 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=\"profile\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Profile 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 }\" x-data=\"profile\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -37,7 +37,15 @@ func Profile(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><div class=\"mb-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Profile Settings</h1><p style=\"color: var(--text-secondary)\">Manage your account information and preferences</p></div><div class=\"space-y-8\"><!-- Account Information & Password - uses reusable ProfileForm --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8 flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("user", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Profile Settings</h1><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary)\">Manage your account information and preferences</p></div></div><div class=\"space-y-6\"><div class=\"card p-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -45,7 +53,23 @@ func Profile(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><!-- Danger Zone --><div class=\"card p-6 rounded-lg border\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"><h3 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Danger Zone</h3><p style=\"color: var(--text-secondary)\" class=\"mb-4\">Once you delete your account, there is no going back. Please be certain.</p><button @click=\"confirmDeleteAccount()\" class=\"px-4 py-2 rounded text-sm\" style=\"background-color: #dc2626; color: white;\">Remove My Account</button></div></div></main></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"card p-6\"><div class=\"flex items-start gap-3 mb-4\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: color-mix(in srgb, var(--status-danger) 18%, transparent); color: var(--status-danger);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><div><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Danger Zone</h3><p class=\"text-sm mt-0.5\" style=\"color: var(--text-secondary)\">Once you delete your account, there is no going back. Please be certain.</p></div></div><button @click=\"confirmDeleteAccount()\" class=\"btn btn-danger\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Remove My Account</button></div></div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user