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
+101
View File
@@ -0,0 +1,101 @@
function getAuthHeader(): string {
const token = localStorage.getItem('token');
return token ? `Bearer ${token}` : '';
}
async function apiGet(url: string): Promise<Response> {
return fetch(`/api${url}`, {
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
}
});
}
async function apiPost(url: string, data?: unknown): Promise<Response> {
return fetch(`/api${url}`, {
method: 'POST',
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
},
body: data ? JSON.stringify(data) : undefined
});
}
async function apiPut(url: string, data?: unknown): Promise<Response> {
return fetch(`/api${url}`, {
method: 'PUT',
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
},
body: data ? JSON.stringify(data) : undefined
});
}
async function apiDelete(url: string): Promise<Response> {
return fetch(`/api${url}`, {
method: 'DELETE',
headers: {
'Authorization': getAuthHeader()
}
});
}
async function apiPatch(url: string, data?: unknown): Promise<Response> {
return fetch(`/api${url}`, {
method: 'PATCH',
headers: {
'Authorization': getAuthHeader(),
'Content-Type': 'application/json'
},
body: data ? JSON.stringify(data) : undefined
});
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json();
}
async function handleVoidResponse(response: Response): Promise<void> {
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
}
function handleError(error: unknown, context: string): void {
console.error(`${context}:`, error);
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
if ((window as any).showToast?.error) {
(window as any).showToast.error(message);
}
}
(window as any).api = {
get: apiGet,
post: apiPost,
put: apiPut,
delete: apiDelete,
patch: apiPatch,
handleResponse,
handleVoidResponse,
handleError
};
export {
getAuthHeader,
apiGet,
apiPost,
apiPut,
apiDelete,
apiPatch,
handleResponse,
handleVoidResponse,
handleError
};
+137
View File
@@ -0,0 +1,137 @@
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;
}
(window as any).dom = {
escapeHtml,
querySelector,
querySelectorAll,
getElementById,
createElement,
showElement,
hideElement,
toggleElement,
setTextContent,
setInnerHTML,
addClass,
removeClass,
toggleClass,
hasClass
};
export {
escapeHtml,
querySelector,
querySelectorAll,
getElementById,
createElement,
showElement,
hideElement,
toggleElement,
setTextContent,
setInnerHTML,
addClass,
removeClass,
toggleClass,
hasClass
};
+117
View File
@@ -0,0 +1,117 @@
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();
}
(window as any).events = {
onDelegatedClick,
onDelegatedSubmit,
onDelegatedChange,
onDelegatedKeydown,
getDataAttribute,
setDataAttribute,
onClick,
onSubmit,
onChange,
onKeydown,
onInput,
preventDefault,
stopPropagation
};
export {
onDelegatedClick,
onDelegatedSubmit,
onDelegatedChange,
onDelegatedKeydown,
getDataAttribute,
setDataAttribute,
onClick,
onSubmit,
onChange,
onKeydown,
onInput,
preventDefault,
stopPropagation
};
+83
View File
@@ -0,0 +1,83 @@
function getToken(): string | null {
return localStorage.getItem('token');
}
function setToken(token: string): void {
localStorage.setItem('token', token);
}
function removeToken(): void {
localStorage.removeItem('token');
}
function getRefreshToken(): string | null {
return localStorage.getItem('refresh_token');
}
function setRefreshToken(token: string): void {
localStorage.setItem('refresh_token', token);
}
function removeRefreshToken(): void {
localStorage.removeItem('refresh_token');
}
function getTheme(): string {
return localStorage.getItem('theme') || 'tokyo-night';
}
function setTheme(theme: string): void {
localStorage.setItem('theme', theme);
}
function getSelectedLibrary(): string | null {
return localStorage.getItem('selectedLibrary');
}
function setSelectedLibrary(libraryId: string): void {
localStorage.setItem('selectedLibrary', libraryId);
}
function getSelectedBook(): string | null {
return localStorage.getItem('selectedBook');
}
function setSelectedBook(bookId: string): void {
localStorage.setItem('selectedBook', bookId);
}
function clearAll(): void {
localStorage.clear();
}
(window as any).storage = {
getToken,
setToken,
removeToken,
getRefreshToken,
setRefreshToken,
removeRefreshToken,
getTheme,
setTheme,
getSelectedLibrary,
setSelectedLibrary,
getSelectedBook,
setSelectedBook,
clearAll
};
export {
getToken,
setToken,
removeToken,
getRefreshToken,
setRefreshToken,
removeRefreshToken,
getTheme,
setTheme,
getSelectedLibrary,
setSelectedLibrary,
getSelectedBook,
setSelectedBook,
clearAll
};
+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;
}