feat(typescript): add core infrastructure modules

- Add centralized API type definitions (types/api.d.ts)
  - Interfaces for all API responses matching Go handler JSON
  - Snake_case field names matching actual API responses
  - Source file references in comments for verification

- Add API client module (api.ts)
  - Procedural get/post/put/delete functions
  - Automatic auth header injection
  - Exported to window for cross-module access

- Add DOM utilities (dom.ts)
  - escapeHtml for safe HTML rendering
  - querySelector wrappers with null checks
  - Element creation helpers

- Add event delegation helpers (events.ts)
  - Reusable event delegation pattern
  - Data attribute selectors for dynamic content

- Add localStorage wrapper (storage.ts)
  - Type-safe token management
  - Theme persistence helpers
This commit is contained in:
2026-02-18 16:40:29 -05:00
parent 364de1ee93
commit 60c5a093b5
7 changed files with 751 additions and 2447 deletions
+313
View File
@@ -0,0 +1,313 @@
// ============================================
// API Type Definitions
// ============================================
// These types match the JSON responses from /api/* endpoints.
// Source of truth: Check what the endpoint ACTUALLY returns:
// 1. Database layer: internal/database/queries.sql.go (SearchMediaItemsRow, etc.)
// 2. Handler structs: internal/handlers/*.go (check json:"..." tags)
// 3. Test by calling endpoint and inspecting JSON response
//
// When API contracts change:
// 1. Find the endpoint function in internal/handlers/*.go
// 2. Check what it returns (database row or struct)
// 3. Check the JSON tags: `json:"field_name"`
// 4. Map pgtype fields to TypeScript types:
// - pgtype.Text → string | undefined
// - pgtype.UUID → string
// - pgtype.Timestamp → string (ISO datetime)
// - pgtype.Numeric → number or string (for precision)
// 5. Update the interface below with snake_case field names
// 6. Run Bruno tests to verify
// ============================================
// Matches database.SearchMediaItemsRow from /api/media-items/search
// Source: internal/database/queries.sql.go:6123-6170 SearchMediaItemsRow
// Endpoint: internal/handlers/media.go:SearchMediaItems()
// Note: internal/handlers/search.go has an unused MediaItemSummary - ignore it
// Used in: search.ts
export interface MediaItemSummary {
id: string;
library_id: string;
title: string;
author?: string;
isbn?: string;
description?: string;
file_path: string;
file_size?: number;
mime_type?: string;
cover_image_path?: string;
series?: string;
series_number?: number;
tags?: string[];
asin?: string;
date_published?: string;
publisher?: string;
contributors?: string[];
language?: string;
edition?: string;
page_count?: number;
genre?: string;
copyright_year?: number;
goodreads_id?: string;
openlibrary_id?: string;
google_books_id?: string;
added_by_admin_id?: string;
created_at: string;
updated_at: string;
format_group: string;
format_mimetype?: string;
is_reflowable?: boolean;
has_fixed_layout?: boolean;
total_characters?: number;
chapter_count?: number;
entitlement_id?: string;
revision_number?: number;
kobo_content_id?: string;
kobo_metadata?: string;
tags_search?: string[];
contributors_search?: string[];
file_sha256?: string;
opf_identifier?: string;
opf_uuid?: string;
hash_confidence?: string;
library_name: string;
library_type_name: string;
}
// Matches handlers.CollectionData / CollectionResponse JSON response
// Source: internal/handlers/collections.go:123-131 CollectionResponse
// Used in: collections.ts
export interface CollectionData {
id: string;
name: string;
description: string;
color: string;
icon: string;
auto_assign_rules?: unknown;
created_at: string;
}
// Matches handlers.BookInfo JSON response (internal/handlers/collections.go:66-71)
// JSON tags: media_item_id, title, author, cover_image_path
// Used in: collections.templ (server-rendered), collections.ts
export interface BookInfo {
media_item_id: string;
title: string;
author: string;
cover_image_path: string;
}
// Matches handlers.UnlinkedBookData JSON response
// Used in: unlinked_books.ts, unlinked_books.templ
export interface UnlinkedBookData {
progress_id: string;
device_id: string;
device_name: string;
device_type: 'koreader' | 'kobo' | 'web';
title_from_device: string;
file_path: string;
sha256: string;
last_sync_time: string;
confidence_score: number;
potential_matches: PotentialMatchData[];
}
export interface PotentialMatchData {
media_item_id: string;
title: string;
author: string;
confidence: number;
cover_image_path?: string;
}
// Matches collection rule objects
// Used in: collection_rules.ts
export interface CollectionRule {
id: string;
field: 'genre' | 'series' | 'author' | 'language' | 'publisher' | 'copyright_year' | 'tags';
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with' | 'greater_than' | 'less_than';
value: string;
enabled: boolean;
priority: number;
}
// Matches API test rule responses
// Used in: collection_rules.ts (test results)
export interface TestRuleMatch {
title: string;
author: string;
cover_image_path?: string;
}
// Matches handlers.SearchResponse (internal/handlers/search.go)
export interface SearchResponse {
results: SearchBookResponse[];
total: number;
}
export interface SearchBookResponse {
id: string;
title: string;
authors: SearchAuthor[];
}
export interface SearchAuthor {
first_name: string;
last_name: string;
}
// Matches AuthResponse (internal/handlers/auth.go:59-65)
export interface AuthResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
user: UserProfile;
}
export interface UserProfile {
id: string;
email: string;
username: string;
first_name?: string;
last_name?: string;
role: string;
theme?: string;
}
// Matches handlers.ReadingStatsResponse (internal/handlers/analytics.go:26-35)
// Used in: analytics.ts
export interface ReadingStatsResponse {
total_books_read: number;
total_pages_read: number;
total_reading_time_minutes: number;
average_session_time_minutes: number;
longest_session_minutes: number;
most_active_day_of_week: string;
completion_rate: number;
daily_reading_minutes: DailyReading[];
}
export interface DailyReading {
date: string;
minutes: number;
pages: number;
}
// Matches handlers.DeviceUsageResponse (internal/handlers/analytics.go:43-45)
// Note: Response is wrapped: { devices: DeviceUsage[] }
// Used in: analytics.ts
export interface DeviceUsageResponse {
devices: DeviceUsage[];
}
export interface DeviceUsage {
device_id: string;
device_name: string;
device_type: string;
sync_count: number;
last_sync: string;
total_time_seconds: number;
total_time_minutes: number;
}
// Matches handlers.PopularBooksResponse (internal/handlers/analytics.go:57-59)
// Note: Response is wrapped: { books: PopularBook[] }
// Used in: analytics.ts
export interface PopularBooksResponse {
books: PopularBook[];
}
export interface PopularBook {
media_item_id: string;
title: string;
author: string;
read_count: number;
avg_completion: number;
last_read: string;
}
// Matches handlers.QueueItemResponse (internal/handlers/queue.go:35-51)
// Used in: queue.ts
export interface QueueItemResponse {
id: string;
device_id: string;
device_name: string;
device_type: string;
media_item_id?: string;
media_title?: string;
user_email: string;
sync_type: string;
priority: number;
attempts: number;
max_attempts: number;
status: string;
error_message?: string;
created_at: string;
processed_at?: string;
}
// Matches handlers.QueueStatsResponse (internal/handlers/queue.go:27-33)
// Used in: queue.ts
export interface QueueStatsResponse {
pending_count: number;
processing_count: number;
failed_count: number;
completed_count: number;
total_count: number;
}
// Matches handlers.ConflictDetailResponse (internal/handlers/conflicts.go:42-53)
// Used in: conflicts.ts
export interface ConflictDetailResponse {
id: string;
media_item_id: string;
media_item_title: string;
conflict_type: string;
conflict_data: Record<string, ConflictSourceData>;
resolution_status: string;
resolution_data?: Record<string, unknown>;
resolved_by?: string;
resolved_at?: string;
created_at: string;
}
// Matches handlers.ConflictSourceData (internal/handlers/conflicts.go:36-40)
export interface ConflictSourceData {
source: string;
timestamp: string;
data: Record<string, unknown>;
}
// Matches handlers.ConflictListResponse (internal/handlers/conflicts.go:55-59)
// Used in: conflicts.ts
export interface ConflictListResponse {
conflicts: ConflictDetailResponse[];
total: number;
unresolved: number;
}
// Matches handlers.ConflictResolveResponse (internal/handlers/conflicts.go:61-65)
// Used in: conflicts.ts
export interface ConflictResolveResponse {
conflict_resolved: boolean;
applied_to: Record<string, boolean>;
devices_synced: string[];
}
// Matches handlers.BulkResolveResponse (internal/handlers/conflicts.go:424-429)
// Used in: conflicts.ts
export interface BulkResolveResponse {
results: ConflictResult[];
total: number;
success: number;
failed: number;
}
// Matches handlers.ConflictResult (internal/handlers/conflicts.go:431-436)
export interface ConflictResult {
conflict_id: string;
status: string;
error?: string;
winner?: string;
}