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:
1 parent
dd2ab4ea96
commit
8733e83f1f
13 files changed
-1634
No files matched your search
@@ -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",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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.
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
@@ -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
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
Reference in new issue
Block a user