style: apply gofmt across Go sources

Repo-wide gofmt sweep: const-block alignment, doc-comment spacing,
statement-after-brace fix in media.go, trailing blank lines. No
semantic changes — whitespace-insensitive diff is empty for every
file in this commit.
This commit is contained in:
John O'Keefe committed 2026-10-25 11:05:37 -04:00
1 parent dd2ab4ea96
commit 8733e83f1f
13 files changed
-1634

No files matched your search

-154
View File
@@ -1,154 +0,0 @@
package handlers
import (
"bookhoard/internal/database"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type SyncHandler struct {
db *database.Queries
}
func NewSyncHandler(db *database.Queries) *SyncHandler {
return &SyncHandler{db: db}
}
type UnlinkedBookResponse struct {
Unlinked []UnlinkedBookItem `json:"unlinked"`
Total int `json:"total"`
}
type UnlinkedBookItem struct {
UnlinkedBookID string `json:"unlinked_book_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
ContentId string `json:"content_id"`
FilePath string `json:"file_path,omitempty"`
Title string `json:"title,omitempty"`
Author string `json:"author,omitempty"`
ConfidenceScore float64 `json:"confidence_score"`
LastSeenAt time.Time `json:"last_seen_at"`
CreatedAt time.Time `json:"created_at"`
}
type LinkBookRequest struct {
UnlinkedBookID string `json:"unlinked_book_id"`
MediaItemID string `json:"media_item_id"`
ConfidenceScore float64 `json:"confidence_score"`
}
type LinkBookResponse struct {
Status string `json:"status"`
UnlinkedBookID string `json:"unlinked_book_id"`
MediaItemID string `json:"media_item_id"`
Message string `json:"message"`
}
// GetUnlinkedBooks returns all unlinked books for a user's devices
// GET /api/sync/unlinked-books
func (h *SyncHandler) GetUnlinkedBooks(c *echo.Context) error {
// Get user from context (assuming auth middleware sets this)
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "unauthorized",
})
}
userID := user.(database.Users).ID
// Get all devices for this user
devices, err := h.db.ListDevicesByUser(c.Request().Context(), pgtype.UUID{Bytes: userID.Bytes, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to fetch devices",
})
}
var allUnlinkedBooks []UnlinkedBookItem
// For each device, get unlinked books
for _, device := range devices {
unlinkedBooks, err := h.db.GetUnlinkedBooksByDevice(c.Request().Context(), pgtype.UUID{Bytes: device.ID.Bytes, Valid: true})
if err != nil {
continue
}
for _, ub := range unlinkedBooks {
allUnlinkedBooks = append(allUnlinkedBooks, UnlinkedBookItem{
UnlinkedBookID: uuid.UUID(ub.ID.Bytes).String(),
DeviceID: uuid.UUID(ub.DeviceID.Bytes).String(),
DeviceName: ub.DeviceName,
DeviceType: ub.DeviceType,
ContentId: ub.ContentID,
FilePath: ub.FilePath.String,
Title: ub.Title.String,
Author: ub.Author.String,
ConfidenceScore: ub.ConfidenceScore.Float64,
LastSeenAt: ub.LastSeenAt.Time,
CreatedAt: ub.CreatedAt.Time,
})
}
}
return c.JSON(http.StatusOK, UnlinkedBookResponse{
Unlinked: allUnlinkedBooks,
Total: len(allUnlinkedBooks),
})
}
// LinkBook manually links an unlinked book to a media item
// POST /api/sync/link-book
func (h *SyncHandler) LinkBook(c *echo.Context) error {
var req LinkBookRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request format",
})
}
// Parse UUIDs
unlinkedBookID, err := uuid.Parse(req.UnlinkedBookID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid unlinked_book_id",
})
}
mediaItemID, err := uuid.Parse(req.MediaItemID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid media_item_id",
})
}
// Get the unlinked books to find the one we're looking for
// We need to use GetAllUnlinkedBooks or create a new query
// For now, let's link directly using LinkUnlinkedBook
_, err = h.db.LinkUnlinkedBook(c.Request().Context(), database.LinkUnlinkedBookParams{
ID: pgtype.UUID{Bytes: unlinkedBookID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to link book",
})
}
// Note: We can't create a device catalog entry without knowing the device_id and content_id
// In a real implementation, we'd first fetch the unlinked book, then create the catalog entry
return c.JSON(http.StatusOK, LinkBookResponse{
Status: "linked",
UnlinkedBookID: req.UnlinkedBookID,
MediaItemID: req.MediaItemID,
Message: "Book successfully linked",
})
}
-94
View File
@@ -1,94 +0,0 @@
package middleware
import (
"errors"
"fmt"
"net/http"
"github.com/labstack/echo/v5"
)
// ErrorResponse represents a standardized error response
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"`
}
// HTTPError represents an HTTP error with additional context
type HTTPError struct {
Code int
Message string
Err error
}
// Error implements the error interface
func (e *HTTPError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
// Common error types
var (
ErrBadRequest = &HTTPError{Code: http.StatusBadRequest, Message: "Bad request"}
ErrUnauthorized = &HTTPError{Code: http.StatusUnauthorized, Message: "Unauthorized"}
ErrForbidden = &HTTPError{Code: http.StatusForbidden, Message: "Forbidden"}
ErrNotFound = &HTTPError{Code: http.StatusNotFound, Message: "Resource not found"}
ErrConflict = &HTTPError{Code: http.StatusConflict, Message: "Resource conflict"}
ErrTooManyRequest = &HTTPError{Code: http.StatusTooManyRequests, Message: "Too many requests"}
ErrInternal = &HTTPError{Code: http.StatusInternalServerError, Message: "Internal server error"}
)
// NewHTTPError creates a new HTTP error
func NewHTTPError(code int, message string, err error) *HTTPError {
return &HTTPError{
Code: code,
Message: message,
Err: err,
}
}
// RespondWithError sends a standardized error response
func RespondWithError(c *echo.Context, code int, message string, err error) error {
response := ErrorResponse{
Error: message,
Message: "",
Code: "",
}
// Include original error message in development mode if available
if err != nil {
response.Message = err.Error()
}
return c.JSON(code, response)
}
// RespondWithHTTPError sends an HTTPError as JSON
func RespondWithHTTPError(c *echo.Context, httpErr *HTTPError) error {
response := ErrorResponse{
Error: httpErr.Message,
}
if httpErr.Err != nil {
response.Message = httpErr.Err.Error()
}
return c.JSON(httpErr.Code, response)
}
// WrapHandler wraps an echo.HandlerFunc to return standardized errors
func WrapHandler(fn func(*echo.Context) error) echo.HandlerFunc {
return func(c *echo.Context) error {
err := fn(c)
if err != nil {
if httpErr, ok := errors.AsType[*HTTPError](err); ok {
return RespondWithHTTPError(c, httpErr)
}
return RespondWithError(c, http.StatusInternalServerError, "Internal server error", err)
}
return nil
}
}
File renamed without changes.
-53
View File
@@ -1,53 +0,0 @@
package middleware
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// TransactionManager handles database transactions
type TransactionManager struct {
pool *pgxpool.Pool
}
// NewTransactionManager creates a new transaction manager
func NewTransactionManager(pool *pgxpool.Pool) *TransactionManager {
return &TransactionManager{pool: pool}
}
// WithTx runs a function within a database transaction
// If the function returns an error, the transaction is rolled back
// If the function succeeds, the transaction is committed
func (tm *TransactionManager) WithTx(ctx context.Context, fn func(pgx.Tx) error) error {
tx, err := tm.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
// Ensure the transaction is handled properly
defer func() {
if p := recover(); p != nil {
// A panic occurred, rollback and re-panic
_ = tx.Rollback(ctx)
panic(p)
}
}()
if err := fn(tx); err != nil {
// Function returned error, rollback
if rbErr := tx.Rollback(ctx); rbErr != nil {
return fmt.Errorf("function error: %v, rollback error: %w", err, rbErr)
}
return err
}
// Success, commit
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
-257
View File
@@ -1,257 +0,0 @@
package sync
import (
"bookhoard/internal/database"
"context"
"log"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
const (
OnlineThreshold = 5 * time.Minute
OfflineCheckInterval = 2 * time.Minute
)
type OfflineDetector struct {
db *database.Queries
queue *SyncQueueProcessor
checkInterval time.Duration
onlineThreshold time.Duration
}
type DeviceOnlineStatus struct {
DeviceID pgtype.UUID
DeviceName string
DeviceType string
IsOnline bool
LastSeen time.Time
UserID pgtype.UUID
}
func NewOfflineDetector(db *database.Queries, queue *SyncQueueProcessor) *OfflineDetector {
return &OfflineDetector{
db: db,
queue: queue,
checkInterval: OfflineCheckInterval,
onlineThreshold: OnlineThreshold,
}
}
func (d *OfflineDetector) Start(ctx context.Context) {
log.Printf("Starting offline detector (threshold: %v)", d.onlineThreshold)
ticker := time.NewTicker(d.checkInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("Offline detector stopped")
return
case <-ticker.C:
d.checkDeviceStatus(ctx)
}
}
}
func (d *OfflineDetector) checkDeviceStatus(ctx context.Context) {
users, err := d.db.ListUsers(ctx)
if err != nil {
log.Printf("Failed to list users for offline check: %v", err)
return
}
for _, user := range users {
devices, err := d.db.ListDevicesByUser(ctx, user.ID)
if err != nil {
continue
}
for _, device := range devices {
if !device.SyncEnabled.Bool {
continue
}
status := d.getDeviceStatus(device)
if !status.IsOnline {
d.handleOfflineDevice(ctx, status)
} else if d.wasOffline(ctx, status.DeviceID) {
d.handleReconnectedDevice(ctx, status)
}
d.updateDeviceStatus(ctx, status)
}
}
}
func (d *OfflineDetector) getDeviceStatus(device database.Devices) DeviceOnlineStatus {
lastSeen := time.Now()
if device.LastSeen.Valid {
lastSeen = device.LastSeen.Time
}
isOnline := time.Since(lastSeen) < d.onlineThreshold
return DeviceOnlineStatus{
DeviceID: device.ID,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
IsOnline: isOnline,
LastSeen: lastSeen,
UserID: device.UserID,
}
}
func (d *OfflineDetector) wasOffline(ctx context.Context, deviceID pgtype.UUID) bool {
device, err := d.db.GetDevice(ctx, deviceID)
if err != nil {
return false
}
if !device.LastSeen.Valid {
return false
}
timeSinceLastSeen := time.Since(device.LastSeen.Time)
return timeSinceLastSeen >= d.onlineThreshold && timeSinceLastSeen < d.onlineThreshold*2
}
func (d *OfflineDetector) handleOfflineDevice(ctx context.Context, status DeviceOnlineStatus) {
log.Printf("Device offline detected: %s (%s)", status.DeviceName, status.DeviceType)
d.setOfflineMode(ctx, status.DeviceID)
}
func (d *OfflineDetector) handleReconnectedDevice(ctx context.Context, status DeviceOnlineStatus) {
log.Printf("Device reconnected: %s (%s)", status.DeviceName, status.DeviceType)
d.setOnlineMode(ctx, status.DeviceID)
d.processQueuedItems(ctx, status.DeviceID)
}
func (d *OfflineDetector) setOfflineMode(ctx context.Context, deviceID pgtype.UUID) {
// Targeted flip: the full-row UpdateDevice used here zeroed the
// device name, auto_sync, frequency, and metadata on every
// transition (pg zero values are not "leave alone").
_, err := d.db.SetDeviceSyncEnabled(ctx, database.SetDeviceSyncEnabledParams{
ID: deviceID,
SyncEnabled: pgtype.Bool{Bool: false, Valid: true},
})
if err != nil {
log.Printf("Failed to set offline mode for device: %v", err)
}
}
func (d *OfflineDetector) setOnlineMode(ctx context.Context, deviceID pgtype.UUID) {
// Targeted flip: the full-row UpdateDevice used here zeroed the
// device name, auto_sync, frequency, and metadata on every
// transition (pg zero values are not "leave alone").
_, err := d.db.SetDeviceSyncEnabled(ctx, database.SetDeviceSyncEnabledParams{
ID: deviceID,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
})
if err != nil {
log.Printf("Failed to set online mode for device: %v", err)
}
}
func (d *OfflineDetector) updateDeviceStatus(ctx context.Context, status DeviceOnlineStatus) {
_, err := d.db.UpdateDeviceLastSeen(ctx, status.DeviceID)
if err != nil {
log.Printf("Failed to update device last seen: %v", err)
}
}
func (d *OfflineDetector) processQueuedItems(ctx context.Context, deviceID pgtype.UUID) {
log.Printf("Processing queued items for reconnected device %s", deviceID.Bytes)
items, err := d.db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
DeviceID: deviceID,
RowLimit: 50,
})
if err != nil {
log.Printf("Failed to list queued items for device: %v", err)
return
}
log.Printf("Found %d queued items for device", len(items))
priorityItems := make([]database.SyncQueue, 0)
for _, item := range items {
if item.Priority.Int32 <= PriorityBookCompletion {
priorityItems = append(priorityItems, item)
}
}
if len(priorityItems) > 0 {
log.Printf("Processing %d priority items for device", len(priorityItems))
for _, item := range priorityItems {
_, err := d.db.UpdateSyncQueueItemStatus(ctx, database.UpdateSyncQueueItemStatusParams{
ID: item.ID,
Status: pgtype.Text{String: "pending", Valid: true},
ErrorMessage: pgtype.Text{},
})
if err != nil {
log.Printf("Failed to re-priority queue item: %v", err)
}
}
}
}
func (d *OfflineDetector) ForceReconnectDevice(ctx context.Context, deviceID pgtype.UUID) error {
device, err := d.db.GetDevice(ctx, deviceID)
if err != nil {
return err
}
status := d.getDeviceStatus(device)
status.IsOnline = true
status.LastSeen = time.Now()
d.handleReconnectedDevice(ctx, status)
d.updateDeviceStatus(ctx, status)
return nil
}
func (d *OfflineDetector) GetDeviceStatus(ctx context.Context, deviceID pgtype.UUID) (*DeviceOnlineStatus, error) {
device, err := d.db.GetDevice(ctx, deviceID)
if err != nil {
return nil, err
}
deviceStatus := d.getDeviceStatus(device)
return &deviceStatus, nil
}
func (d *OfflineDetector) ListOfflineDevices(ctx context.Context) ([]DeviceOnlineStatus, error) {
users, err := d.db.ListUsers(ctx)
if err != nil {
return nil, err
}
var offlineDevices []DeviceOnlineStatus
for _, user := range users {
devices, err := d.db.ListDevicesByUser(ctx, user.ID)
if err != nil {
continue
}
for _, device := range devices {
status := d.getDeviceStatus(device)
if !status.IsOnline {
offlineDevices = append(offlineDevices, status)
}
}
}
return offlineDevices, nil
}
-13
View File
@@ -1,13 +0,0 @@
package sync
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestOfflineDetector_ConstantValues(t *testing.T) {
assert.Equal(t, 5*time.Minute, OnlineThreshold)
assert.Equal(t, 2*time.Minute, OfflineCheckInterval)
}
-114
View File
@@ -1,114 +0,0 @@
package templates
templ APIExplorer(explorer APIExplorerData) {
if !explorer.IsLoggedIn {
<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="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);">{ explorer.Endpoint.RequestBody }</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);">{ explorer.Endpoint.Response }</code></pre>
</div>
<div class="flex gap-2">
<button @click={ "copyToClipboard(" + JSONArg(explorer.Endpoint.RequestBody) + ")" } class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Request
</button>
<button @click={ "copyToClipboard(" + JSONArg(explorer.Endpoint.Response) + ")" } class="btn btn-secondary text-sm">
@Icon("copy", "h-4 w-4")
Copy Response
</button>
</div>
</div>
} else {
<div class="card p-6 mt-8 space-y-5" x-data="apiExplorerDoc" x-init={ "initAPIExplorerDoc(" + JSONArg(explorer.Endpoint.Path) + ", " + JSONArg(explorer.Endpoint.RequestBody) + ", " + JSONArg(explorer.Endpoint.Response) + ")" }>
<div class="flex items-center gap-2 flex-wrap">
if explorer.Endpoint.Method == "GET" {
<span class="badge status-completed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "DELETE" {
<span class="badge status-failed">{ explorer.Endpoint.Method }</span>
} else if explorer.Endpoint.Method == "POST" {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ explorer.Endpoint.Method }</span>
} else {
<span class="badge status-processing">{ explorer.Endpoint.Method }</span>
}
<code class="text-sm" style="color: var(--text-secondary);">{ explorer.Endpoint.Path }</code>
</div>
<div class="flex gap-2">
<button class="mode-btn btn btn-secondary text-sm" id="mock-btn" @click="showDocMode('mock')">Mock Data</button>
<button class="mode-btn btn btn-primary text-sm" id="real-btn" @click="showDocMode('real')">Try It Out</button>
</div>
<div>
<label for="http-method" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary);">Method</label>
<select id="http-method" class="input">
<option value="GET" selected?={ explorer.Endpoint.Method == "GET" }>GET</option>
<option value="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>
<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">
@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>
}
}
// APIExplorerData holds data for the API explorer component
type APIExplorerData struct {
Endpoint EndpointInfo
IsLoggedIn bool
}
// EndpointInfo holds information about an API endpoint
type EndpointInfo struct {
Method string
Path string
RequestBody string
Response string
Description string
}
-178
View File
@@ -1,178 +0,0 @@
package templates
import (
"bookhoard/internal/handlers"
"fmt"
)
templ Queue(user User, queueItems []handlers.QueueItemResponse, stats handlers.QueueStatsResponse) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Sync Queue - 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="queue" class={ "theme-" + user.Theme }>
@Header(user, "/queue")
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="flex justify-between items-center gap-4 flex-wrap">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("clock", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Queue</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Monitor and manage offline sync queue</p>
</div>
<div class="flex space-x-3">
<button @click="processPendingItems()" class="btn btn-primary">
@Icon("play", "h-4 w-4")
Process Queue
</button>
<button @click="clearFailedItems()" class="btn btn-secondary">Clear Failed</button>
<button @click="clearAllItems()" class="btn btn-danger">Clear All</button>
</div>
</div>
<div id="stats-bar" class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-warning);">{ stats.PendingCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Pending</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-info);">{ stats.ProcessingCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Processing</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-success);">{ stats.CompletedCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Completed</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold" style="color: var(--status-danger);">{ stats.FailedCount }</div>
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Failed</div>
</div>
</div>
</div>
<div class="card p-4 mb-6">
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Status Filter</label>
<select
id="status-filter"
onchange="filterQueue()"
class="input"
>
<option value="all">All Items</option>
<option value="pending" selected>Pending</option>
<option value="processing">Processing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Sync Type</label>
<select
id="type-filter"
onchange="filterQueue()"
class="input"
>
<option value="all">All Types</option>
<option value="progress">Progress</option>
<option value="note">Notes</option>
<option value="highlight">Highlights</option>
<option value="bookmark">Bookmarks</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Device</label>
<select
id="device-filter"
onchange="filterQueue()"
class="input"
>
<option value="all">All Devices</option>
</select>
</div>
<div class="flex-1"></div>
<button @click="refreshQueue()" class="btn btn-secondary">
@Icon("refresh", "h-4 w-4")
Refresh
</button>
</div>
</div>
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
<div class="loading-spinner mx-auto mb-4"></div>
<p>Loading queue...</p>
</div>
<div id="empty-state" class="hidden card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">Queue Empty</h3>
<p style="color: var(--text-secondary)">No items in the sync queue</p>
</div>
<div id="queue-list" class="space-y-3">
if len(queueItems) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">Queue Empty</h3>
<p style="color: var(--text-secondary)">No items in the sync queue</p>
</div>
}
for _, item := range queueItems {
<div class="card p-4 transition-colors" style="cursor: default;">
<div class="flex justify-between items-start mb-2 gap-2">
<div class="flex items-center gap-2 flex-wrap">
<span class={ "badge status-" + item.Status }>{ item.Status }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ item.SyncType }</span>
</div>
<span class={ fmt.Sprintf("badge priority-%d", item.Priority) }>P{ item.Priority }</span>
</div>
<div class="text-sm mb-2" style="color: var(--text-secondary)">
Device ID: { item.DeviceID }
if item.MediaTitle != nil {
| Book: { *item.MediaTitle }
}
</div>
<div class="flex justify-between items-center text-sm" style="color: var(--text-secondary)">
<span>Attempts: { item.Attempts }/{ item.MaxAttempts }</span>
<span>{ item.CreatedAtDisplay }</span>
</div>
if item.ErrorMessage != nil {
<div class="mt-2 text-sm flex items-start gap-2" style="color: var(--status-danger);">
@Icon("alert", "h-4 w-4 shrink-0 mt-0.5")
<span>Error: { *item.ErrorMessage }</span>
</div>
}
</div>
}
</div>
<div id="queue-item-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-2xl my-8" style="box-shadow: var(--shadow-pop);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Queue Item Details</h2>
<button @click="hideQueueItemModal()" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
</button>
</div>
<div id="queue-item-details"></div>
<div class="flex justify-end space-x-3 mt-6">
<button @click="hideQueueItemModal()" class="btn btn-secondary">Close</button>
<button @click="retryQueueItem()" class="btn btn-primary">
@Icon("refresh", "h-4 w-4")
Retry
</button>
</div>
</div>
</div>
</div>
</body>
</html>
}
-160
View File
@@ -1,160 +0,0 @@
import { Alpine } from "./alpine";
import { getToken } from "./storage";
let endpointPath = "";
let exampleResponse: unknown = null;
function initAPIExplorerDoc(path: string, response: string): void {
endpointPath = path;
exampleResponse = JSON.parse(response);
}
function showDocMode(mode: "mock" | "real"): void {
const mockBtn = document.getElementById("mock-btn");
const realBtn = document.getElementById("real-btn");
const responseBody = document.getElementById("response-body");
const responseStatus = document.getElementById("response-status");
const responseTime = document.getElementById("response-time");
const apiResponse = document.querySelector(".api-response");
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
const tryItOut = document.getElementById("try-it-out");
if (
!mockBtn ||
!realBtn ||
!responseBody ||
!responseStatus ||
!responseTime ||
!apiResponse ||
!requestBody ||
!tryItOut
)
return;
if (mode === "mock") {
mockBtn.classList.add("bg-accent", "text-white");
mockBtn.classList.remove("bg-background-primary", "text-text-primary");
realBtn.classList.remove("bg-accent", "text-white");
realBtn.classList.add("bg-background-primary", "text-text-primary");
apiResponse.classList.remove("hidden");
requestBody.readOnly = true;
tryItOut.classList.add("hidden");
responseBody.textContent = JSON.stringify(exampleResponse, null, 2);
responseStatus.textContent = "200 OK";
responseTime.textContent = "Mock";
} else {
realBtn.classList.add("bg-accent", "text-white");
realBtn.classList.remove("bg-background-primary", "text-text-primary");
mockBtn.classList.remove("bg-accent", "text-white");
mockBtn.classList.add("bg-background-primary", "text-text-primary");
apiResponse.classList.add("hidden");
requestBody.readOnly = false;
tryItOut.classList.remove("hidden");
}
}
async function tryDocEndpoint(): Promise<void> {
const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
const responseBody = document.getElementById("response-body");
const responseStatus = document.getElementById("response-status");
const responseTime = document.getElementById("response-time");
const apiResponse = document.querySelector(".api-response");
if (
!methodSelect ||
!requestBody ||
!responseBody ||
!responseStatus ||
!responseTime ||
!apiResponse
)
return;
const method = methodSelect.value;
const body = requestBody.value;
const startTime = Date.now();
try {
const token = getToken();
if (!token) {
throw new Error("No authentication token found");
}
const response = await fetch(endpointPath, {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: ["GET", "DELETE"].includes(method) ? undefined : body,
});
const duration = Date.now() - startTime;
const data = await response.json();
responseStatus.textContent = `${response.status} (${response.statusText})`;
responseTime.textContent = `${duration}ms`;
responseBody.textContent = JSON.stringify(data, null, 2);
apiResponse.classList.remove("hidden");
} catch (error) {
responseStatus.textContent = "Error";
responseBody.textContent = (error as Error).message;
apiResponse.classList.remove("hidden");
}
}
function copyDocRequest(): void {
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
if (requestBody) {
navigator.clipboard.writeText(requestBody.value);
}
}
function copyDocResponse(): void {
const responseBody = document.getElementById("response-body");
if (responseBody) {
navigator.clipboard.writeText(responseBody.textContent || "");
}
}
function generateDocCURL(): void {
const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
if (!methodSelect || !requestBody) return;
const method = methodSelect.value;
const body = requestBody.value;
const token = getToken();
let curl = `curl -X ${method} \\n -H "Content-Type: application/json" \\n -H "Authorization: Bearer ${token}"`;
if (!["GET", "DELETE"].includes(method) && body.trim()) {
curl += ` \\n -d '${body}'`;
}
curl += ` \\n ${endpointPath}`;
navigator.clipboard.writeText(curl);
}
export {
copyDocRequest,
copyDocResponse,
generateDocCURL,
initAPIExplorerDoc,
showDocMode,
tryDocEndpoint,
};
Alpine.data("apiExplorerDoc", () => ({
copyDocRequest,
copyDocResponse,
generateDocCURL,
initAPIExplorerDoc,
showDocMode,
tryDocEndpoint,
}));
-174
View File
@@ -1,174 +0,0 @@
import { showToast } from "./toast";
interface ApiExplorerRequest {
method: string;
endpoint: string;
headers: Record<string, string>;
body?: string;
}
const requestHistory: ApiExplorerRequest[] = [];
function sendApiRequest(): void {
const method = (document.getElementById("api-method") as HTMLSelectElement)?.value || "GET";
const endpoint = (document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
const bodyText = (document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
const token = localStorage.getItem("token");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const request: ApiExplorerRequest = {
method,
endpoint,
headers,
body: bodyText || undefined,
};
addToHistory(request);
const startTime = performance.now();
fetch(endpoint, {
method,
headers,
body: bodyText || undefined,
})
.then(async (response) => {
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
const responseText = await response.text();
let responseData: unknown;
try {
responseData = JSON.parse(responseText);
} catch {
responseData = responseText;
}
displayResponse(response, responseData, duration);
generateCurl(request);
})
.catch((error) => {
displayError(error);
});
}
function displayResponse(response: Response, data: unknown, duration: number): void {
const container = document.getElementById("api-response");
if (!container) return;
const statusColor = response.ok ? "var(--accent)" : "var(--error)";
container.innerHTML = `
<div class="mb-4">
<span class="px-2 py-1 rounded text-sm" style="background-color: ${statusColor}; color: var(--bg-primary)">
${response.status} ${response.statusText}
</span>
<span class="text-sm ml-2" style="color: var(--text-secondary)">${duration}ms</span>
</div>
<pre class="p-4 rounded overflow-auto max-h-96" style="background-color: var(--bg-primary); color: var(--text-primary)">${JSON.stringify(data, null, 2)}</pre>
`;
}
function displayError(error: Error): void {
const container = document.getElementById("api-response");
if (!container) return;
container.innerHTML = `
<div class="p-4 rounded" style="background-color: var(--bg-primary)">
<p style="color: var(--error)">Error: ${error.message}</p>
</div>
`;
}
function generateCurl(request: ApiExplorerRequest): void {
const container = document.getElementById("curl-command");
if (!container) return;
let curl = `curl -X ${request.method} '${request.endpoint}'`;
Object.entries(request.headers).forEach(([key, value]) => {
curl += ` \\\n -H '${key}: ${value}'`;
});
if (request.body) {
curl += ` \\\n -d '${request.body}'`;
}
container.textContent = curl;
}
function addToHistory(request: ApiExplorerRequest): void {
requestHistory.unshift(request);
if (requestHistory.length > 20) {
requestHistory.pop();
}
renderHistory();
}
function renderHistory(): void {
const container = document.getElementById("request-history");
if (!container) return;
if (requestHistory.length === 0) {
container.innerHTML =
'<p class="text-sm p-2" style="color: var(--text-secondary)">No requests yet</p>';
return;
}
container.innerHTML = requestHistory
.slice(0, 10)
.map(
(req, i) => `
<div class="p-2 rounded cursor-pointer hover:bg-opacity-50 transition-colors"
style="background-color: var(--bg-secondary)"
onclick="window.loadFromHistory(${i})">
<span class="text-xs font-mono" style="color: ${req.method === "GET" ? "var(--accent)" : req.method === "POST" ? "var(--success)" : req.method === "DELETE" ? "var(--error)" : "var(--text-primary)"}">${req.method}</span>
<span class="text-xs ml-2" style="color: var(--text-secondary)">${req.endpoint}</span>
</div>
`,
)
.join("");
}
function loadFromHistory(index: number): void {
const request = requestHistory[index];
if (!request) return;
const methodSelect = document.getElementById("api-method") as HTMLSelectElement;
const endpointInput = document.getElementById("api-endpoint") as HTMLInputElement;
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
if (methodSelect) methodSelect.value = request.method;
if (endpointInput) endpointInput.value = request.endpoint;
if (bodyInput) bodyInput.value = request.body || "";
}
function copyCurl(): void {
const curl = document.getElementById("curl-command")?.textContent;
if (curl) {
navigator.clipboard.writeText(curl);
showToast("cURL copied to clipboard", "success");
}
}
function formatJson(): void {
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
if (!bodyInput) return;
try {
const parsed = JSON.parse(bodyInput.value);
bodyInput.value = JSON.stringify(parsed, null, 2);
} catch {
showToast("Invalid JSON", "error");
}
}
export { sendApiRequest, loadFromHistory, copyCurl, formatJson };
-120
View File
@@ -1,120 +0,0 @@
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function querySelector<T extends Element>(selector: string): T | null {
return document.querySelector<T>(selector);
}
function querySelectorAll<T extends Element>(selector: string): NodeListOf<T> {
return document.querySelectorAll<T>(selector);
}
function getElementById<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
function createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
attributes?: Record<string, string>,
children?: (string | Node)[],
): HTMLElementTagNameMap[K] {
const element = document.createElement(tagName);
if (attributes) {
Object.entries(attributes).forEach(([key, value]) => {
if (key === "className") {
element.className = value;
} else if (key === "dataset") {
Object.entries(JSON.parse(value)).forEach(([dataKey, dataValue]) => {
element.dataset[dataKey] = String(dataValue);
});
} else {
element.setAttribute(key, value);
}
});
}
if (children) {
children.forEach((child) => {
if (typeof child === "string") {
element.appendChild(document.createTextNode(child));
} else {
element.appendChild(child);
}
});
}
return element;
}
function showElement(element: HTMLElement | null): void {
if (element) {
element.classList.remove("hidden");
}
}
function hideElement(element: HTMLElement | null): void {
if (element) {
element.classList.add("hidden");
}
}
function toggleElement(element: HTMLElement | null): void {
if (element) {
element.classList.toggle("hidden");
}
}
function setTextContent(element: HTMLElement | null, text: string): void {
if (element) {
element.textContent = text;
}
}
function setInnerHTML(element: HTMLElement | null, html: string): void {
if (element) {
element.innerHTML = html;
}
}
function addClass(element: HTMLElement | null, className: string): void {
if (element) {
element.classList.add(className);
}
}
function removeClass(element: HTMLElement | null, className: string): void {
if (element) {
element.classList.remove(className);
}
}
function toggleClass(element: HTMLElement | null, className: string): void {
if (element) {
element.classList.toggle(className);
}
}
function hasClass(element: HTMLElement | null, className: string): boolean {
return element ? element.classList.contains(className) : false;
}
export {
addClass,
createElement,
escapeHtml,
getElementById,
hasClass,
hideElement,
querySelector,
querySelectorAll,
removeClass,
setInnerHTML,
setTextContent,
showElement,
toggleClass,
toggleElement,
};
-113
View File
@@ -1,113 +0,0 @@
function onDelegatedClick(
selector: string,
handler: (element: HTMLElement, event: MouseEvent) => void,
): void {
document.addEventListener("click", (event: MouseEvent) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function onDelegatedSubmit(
selector: string,
handler: (form: HTMLFormElement, event: Event) => void,
): void {
document.addEventListener("submit", (event: Event) => {
const target = event.target as HTMLElement;
const form = target.closest(selector) as HTMLFormElement | null;
if (form) {
handler(form, event);
}
});
}
function onDelegatedChange(
selector: string,
handler: (element: HTMLElement, event: Event) => void,
): void {
document.addEventListener("change", (event: Event) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function onDelegatedKeydown(
selector: string,
handler: (element: HTMLElement, event: KeyboardEvent) => void,
): void {
document.addEventListener("keydown", (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const element = target.closest(selector) as HTMLElement | null;
if (element) {
handler(element, event);
}
});
}
function getDataAttribute(element: HTMLElement, name: string): string | undefined {
return element.dataset[name];
}
function setDataAttribute(element: HTMLElement, name: string, value: string): void {
element.dataset[name] = value;
}
function onClick(element: HTMLElement | null, handler: (event: MouseEvent) => void): void {
if (element) {
element.addEventListener("click", handler);
}
}
function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("submit", handler);
}
}
function onChange(element: HTMLElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("change", handler);
}
}
function onKeydown(element: HTMLElement | null, handler: (event: KeyboardEvent) => void): void {
if (element) {
element.addEventListener("keydown", handler);
}
}
function onInput(element: HTMLElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("input", handler);
}
}
function preventDefault(event: Event): void {
event.preventDefault();
}
function stopPropagation(event: Event): void {
event.stopPropagation();
}
export {
getDataAttribute,
onChange,
onClick,
onDelegatedChange,
onDelegatedClick,
onDelegatedKeydown,
onDelegatedSubmit,
onInput,
onKeydown,
onSubmit,
preventDefault,
setDataAttribute,
stopPropagation,
};
-204
View File
@@ -1,204 +0,0 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
async function refreshQueue(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/queue/all", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const data = await response.json();
renderQueueItems(data.items || []);
}
} catch (error) {
console.error("Failed to refresh queue:", error);
}
}
async function processPendingItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch("/api/queue/process", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Processing queue items", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to process queue:", error);
showToast("Failed to process queue", "error");
}
}
async function clearFailedItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (!confirm("Are you sure you want to clear all failed items?")) return;
try {
const response = await fetch("/api/queue/failed", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Failed items cleared", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to clear failed items:", error);
showToast("Failed to clear items", "error");
}
}
async function clearAllItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (!confirm("Are you sure you want to clear all queue items?")) return;
try {
const response = await fetch("/api/queue/all", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Queue cleared", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to clear queue:", error);
showToast("Failed to clear queue", "error");
}
}
async function retryQueueItem(itemId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/queue/items/${itemId}/retry`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Item queued for retry", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to retry item:", error);
showToast("Failed to retry item", "error");
}
}
async function deleteQueueItem(itemId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/queue/items/${itemId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Item deleted", "success");
refreshQueue();
}
} catch (error) {
console.error("Failed to delete item:", error);
showToast("Failed to delete item", "error");
}
}
function renderQueueItems(items: QueueItemResponse[]): void {
const container = document.getElementById("queue-items");
if (!container) return;
if (items.length === 0) {
container.innerHTML =
'<p class="text-center p-4" style="color: var(--text-secondary)">Queue is empty</p>';
return;
}
container.innerHTML = items
.map(
(item) => `
<div class="p-3 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center">
<div>
<p class="font-medium" style="color: var(--text-primary)">${item.media_title || "Unknown"}</p>
<p class="text-sm" style="color: var(--text-secondary)">${item.status} - ${item.sync_type}</p>
<p class="text-xs" style="color: var(--text-secondary)">Attempts: ${item.attempts}/${item.max_attempts}</p>
</div>
<div class="flex space-x-2">
${item.status === "failed" ? `<button onclick="window.retryQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Retry</button>` : ""}
<button onclick="window.deleteQueueItem('${item.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button>
</div>
</div>
${item.error_message ? `<p class="text-xs mt-2" style="color: var(--accent)">${item.error_message}</p>` : ""}
</div>
`,
)
.join("");
}
function showQueueItemModal(): void {
const modal = document.getElementById("queue-item-modal");
const detailsContainer = document.getElementById("queue-item-details");
if (modal && detailsContainer) {
// TODO: Fetch and display item details
detailsContainer.innerHTML = "<p>Loading item details...</p>";
modal.classList.remove("hidden");
}
}
function hideQueueItemModal(): void {
const modal = document.getElementById("queue-item-modal");
if (modal) {
modal.classList.add("hidden");
}
}
function filterQueue(status: string): void {
const filterValue = status || "all";
// TODO: Implement filtering logic
console.log("Filter queue by:", filterValue);
}
export {
clearAllItems,
clearFailedItems,
deleteQueueItem,
processPendingItems,
refreshQueue,
retryQueueItem,
showQueueItemModal,
hideQueueItemModal,
filterQueue,
};
Alpine.data("queue", () => ({
clearAllItems,
clearFailedItems,
deleteQueueItem,
processPendingItems,
refreshQueue,
retryQueueItem,
showQueueItemModal,
hideQueueItemModal,
filterQueue,
}));