docs(typescript): add TypeScript conversion plan for inline JavaScript
- Create comprehensive plan to convert ~5,500 lines of inline JS to TypeScript - Hybrid SSR + TypeScript CRUD approach (keeps existing JSON API) - Event delegation pattern (no inline onclick handlers) - Shared infrastructure: apiClient, toast, event utilities - Procedural/imperative style (no OOP, classes, inheritance) - 6 phases, 16-21 day timeline - Preserves single API for all clients (web, mobile, plugins) - No new backend routes needed
This commit is contained in:
@@ -0,0 +1,992 @@
|
|||||||
|
# TypeScript Conversion Plan for Bookhoard
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Convert **~5,500 lines of inline JavaScript** across 13 template files into organized TypeScript modules while **preserving the existing Hybrid SSR architecture**. The web UI will remain a client of the JSON API, alongside Kobo, KOReader, and future plugins.
|
||||||
|
|
||||||
|
**Core Strategy:** Server-Side Rendering (SSR) for initial page loads + TypeScript for CRUD operations via existing JSON API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Architecture Analysis
|
||||||
|
|
||||||
|
### Already Converted to TypeScript ✅
|
||||||
|
| Source | Output | Lines | Purpose |
|
||||||
|
|--------|--------|-------|---------|
|
||||||
|
| `web/src/toast.ts` | `toast.js` | 226 | Toast notifications, HTMX/fetch interceptors |
|
||||||
|
| `web/src/theme.ts` | `theme.js` | 130 | Theme management, smooth scrolling |
|
||||||
|
| `web/src/header.ts` | `header.js` | ~100 | Header dropdowns, theme/user menus |
|
||||||
|
| `web/src/device-management.ts` | `device-management.js` | ~70 | Device token copy/regeneration |
|
||||||
|
|
||||||
|
### Remaining Standalone JavaScript ❌
|
||||||
|
| File | Lines | Purpose |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `web/static/search.js` | 275 | Header search with keyboard navigation, debouncing |
|
||||||
|
|
||||||
|
### Inline JavaScript in Templates (~5,500 lines)
|
||||||
|
| Template | Script Lines | Primary Functions |
|
||||||
|
|----------|--------------|-------------------|
|
||||||
|
| `collection_rules.templ` | ~400 | Rule CRUD operations, testing |
|
||||||
|
| `unlinked_books.templ` | ~650 | Book matching, linking, bulk operations |
|
||||||
|
| `collections.templ` | ~500 | Bulk operations, filtering |
|
||||||
|
| `devices.templ` | ~350 | Token regeneration, sync URL display |
|
||||||
|
| `bookshelf.templ` | ~250 | Book viewing, pagination |
|
||||||
|
| `dashboard.templ` | ~300 | Statistics, recent activity |
|
||||||
|
| `api_explorer.templ` | ~200 | API testing, cURL generation |
|
||||||
|
| `admin_library.templ` | ~250 | Admin library scan |
|
||||||
|
| `admin_profile.templ` | ~150 | Admin profile management |
|
||||||
|
| `admin.templ` | ~150 | Admin dashboard actions |
|
||||||
|
| `index.templ` | ~100 | Landing page theme preview |
|
||||||
|
| `login.templ` | ~100 | Login theme selection |
|
||||||
|
| `progress.templ` | ~100 | Reading progress |
|
||||||
|
|
||||||
|
**Total: ~5,500 lines of inline JavaScript to convert**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture: Hybrid SSR + TypeScript CRUD
|
||||||
|
|
||||||
|
### Current Pattern (Already Working)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ User visits /collections │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Go handler: GET /collections │
|
||||||
|
│ 2. Fetches data from CollectionService │
|
||||||
|
│ 3. Renders templates.Collection(user, collections) │
|
||||||
|
│ 4. Returns complete HTML page with data │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
[Page displays instantly with data]
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 5. User clicks "Add Rule" button │
|
||||||
|
│ 6. TypeScript: fetch POST /api/collections/{id}/rules │
|
||||||
|
│ 7. Server returns JSON │
|
||||||
|
│ 8. TypeScript updates DOM │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### What This Preserves
|
||||||
|
|
||||||
|
✅ **Fast initial page loads** - SSR with data
|
||||||
|
✅ **Progressive enhancement** - Works without JavaScript
|
||||||
|
✅ **Single API** - All clients use `/api/*` endpoints
|
||||||
|
✅ **Service layer as source of truth** - All handlers use same services
|
||||||
|
✅ **No handler duplication** - No new HTML endpoints needed
|
||||||
|
✅ **Plugin ecosystem ready** - Plugins use JSON API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed TypeScript Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
web/ts/
|
||||||
|
├── core/
|
||||||
|
│ ├── toast.ts # ✅ Already converted
|
||||||
|
│ ├── theme.ts # ✅ Already converted
|
||||||
|
│ ├── storage.ts # NEW: localStorage wrapper with types
|
||||||
|
│ ├── dom.ts # NEW: DOM utilities (escapeHtml, querySelector)
|
||||||
|
│ └── api.ts # NEW: API client wrapper with auth
|
||||||
|
│
|
||||||
|
├── features/
|
||||||
|
│ ├── search/
|
||||||
|
│ │ ├── search.ts # Convert from search.js
|
||||||
|
│ │ └── types.ts # Search result types (match Go handlers)
|
||||||
|
│ │
|
||||||
|
│ ├── collections/
|
||||||
|
│ │ ├── rules.ts # Convert from inline JS
|
||||||
|
│ │ ├── bulk.ts # Convert from inline JS
|
||||||
|
│ │ └── types.ts # Reuse handlers.Rule, handlers.Collection
|
||||||
|
│ │
|
||||||
|
│ ├── linking/
|
||||||
|
│ │ ├── matcher.ts # Convert from inline JS
|
||||||
|
│ │ ├── bulk-link.ts # Convert from inline JS
|
||||||
|
│ │ ├── manual-link.ts # Convert from inline JS
|
||||||
|
│ │ └── types.ts # Reuse handlers.BookMatch, handlers.Progress
|
||||||
|
│ │
|
||||||
|
│ ├── devices/
|
||||||
|
│ │ ├── token.ts # Convert from inline JS
|
||||||
|
│ │ └── types.ts # Reuse handlers.Device types
|
||||||
|
│ │
|
||||||
|
│ ├── bookshelf/
|
||||||
|
│ │ ├── display.ts # Convert from inline JS
|
||||||
|
│ │ ├── pagination.ts # Convert from inline JS
|
||||||
|
│ │ └── types.ts # Reuse handlers.MediaItem types
|
||||||
|
│ │
|
||||||
|
│ ├── api-explorer/
|
||||||
|
│ │ ├── request.ts # Convert from inline JS
|
||||||
|
│ │ ├── response.ts # Convert from inline JS
|
||||||
|
│ │ └── curl.ts # Convert from inline JS
|
||||||
|
│ │
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── scan.ts # Convert from inline JS
|
||||||
|
│ └── stats.ts # Convert from inline JS
|
||||||
|
│
|
||||||
|
└── shared/
|
||||||
|
├── events.ts # Event delegation utilities
|
||||||
|
└── auth.ts # Token management helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Sharing Strategy
|
||||||
|
|
||||||
|
### Principle: Reuse Go Handler Types
|
||||||
|
|
||||||
|
**❌ DON'T DO THIS:**
|
||||||
|
```typescript
|
||||||
|
// Duplicating types from Go handlers
|
||||||
|
interface Collection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ DO THIS:**
|
||||||
|
```typescript
|
||||||
|
// Use types that match Go handlers exactly
|
||||||
|
// These types are already defined in Go and returned by /api/*
|
||||||
|
interface MediaItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author?: string;
|
||||||
|
library_id: string;
|
||||||
|
library_type_name: 'ebooks' | 'comics' | 'manga';
|
||||||
|
cover_image_path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From handlers.CollectionData
|
||||||
|
interface CollectionData {
|
||||||
|
ID: string;
|
||||||
|
Name: string;
|
||||||
|
Description: string;
|
||||||
|
Color: string;
|
||||||
|
Icon: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Single source of truth (Go handlers)
|
||||||
|
- API returns these types as JSON
|
||||||
|
- TypeScript matches exactly what server provides
|
||||||
|
- No duplication, no drift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversion Phases
|
||||||
|
|
||||||
|
### Phase 1: Complete Standalone File Conversion
|
||||||
|
**Priority: High | Effort: 1 day | Dependencies: None**
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
1. Convert `web/static/search.js` → `web/ts/features/search/search.ts`
|
||||||
|
2. Add proper types for search results (match `handlers.MediaItem`)
|
||||||
|
3. Extract keyboard navigation logic into pure functions
|
||||||
|
4. Update templates to use new TypeScript module
|
||||||
|
|
||||||
|
**Deliverable:** All standalone JavaScript converted to TypeScript
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/ts/features/search/search.ts`
|
||||||
|
- Create: `web/ts/features/search/types.ts`
|
||||||
|
- Delete: `web/static/search.js`
|
||||||
|
- Update: Templates referencing search functions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Core Utilities (Shared Infrastructure)
|
||||||
|
**Priority: High | Effort: 1-2 days | Dependencies: None**
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
1. Create `web/ts/core/storage.ts`
|
||||||
|
- localStorage wrapper with type safety
|
||||||
|
- Token management helpers
|
||||||
|
- Theme persistence
|
||||||
|
|
||||||
|
2. Create `web/ts/core/dom.ts`
|
||||||
|
- escapeHtml utility
|
||||||
|
- querySelector wrappers with null checks
|
||||||
|
- Element creation helpers
|
||||||
|
|
||||||
|
3. Create `web/ts/core/api.ts`
|
||||||
|
- API client wrapper
|
||||||
|
- Automatic auth header injection
|
||||||
|
- Error handling integration with toast system
|
||||||
|
|
||||||
|
4. Create `web/ts/shared/events.ts`
|
||||||
|
- Event delegation helpers
|
||||||
|
- Data attribute selectors
|
||||||
|
- Common event handlers
|
||||||
|
|
||||||
|
**Deliverable:** Reusable utilities for all feature modules
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/ts/core/storage.ts`
|
||||||
|
- Create: `web/ts/core/dom.ts`
|
||||||
|
- Create: `web/ts/core/api.ts`
|
||||||
|
- Create: `web/ts/shared/events.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Low Complexity Features (Search, Header, Admin)
|
||||||
|
**Priority: Medium | Effort: 3-4 days | Dependencies: Phase 2**
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
**3.1 Search Module (from Phase 1)**
|
||||||
|
- Extract search logic from `search.ts`
|
||||||
|
- Add debounced search with proper types
|
||||||
|
- Keyboard navigation state management
|
||||||
|
- Integration with `/api/media-items/search`
|
||||||
|
|
||||||
|
**3.2 Header Dropdowns**
|
||||||
|
- Extract from `header.ts` (already TS)
|
||||||
|
- Add event delegation for dropdowns
|
||||||
|
- Theme switching logic
|
||||||
|
- User menu interactions
|
||||||
|
|
||||||
|
**3.3 Admin Actions**
|
||||||
|
- Quick scan trigger
|
||||||
|
- System stats display
|
||||||
|
- Admin profile updates
|
||||||
|
|
||||||
|
**Deliverable:** Search, header, and admin features in TypeScript
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Refine: `web/ts/features/search/search.ts`
|
||||||
|
- Update: `web/ts/features/search/types.ts`
|
||||||
|
- Create: `web/ts/features/admin/scan.ts`
|
||||||
|
- Create: `web/ts/features/admin/stats.ts`
|
||||||
|
- Update: `web/src/header.ts` (add event delegation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Medium Complexity Features (Collections, Bookshelf, Devices)
|
||||||
|
**Priority: Medium | Effort: 4-5 days | Dependencies: Phase 2, 3**
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
**4.1 Collection Rules**
|
||||||
|
- Convert rule CRUD from `collection_rules.templ`
|
||||||
|
- Use `/api/collections/{id}/rules` endpoints
|
||||||
|
- Rule testing functionality
|
||||||
|
- Bulk rule operations
|
||||||
|
|
||||||
|
**4.2 Bookshelf Display**
|
||||||
|
- Convert from `bookshelf.templ`
|
||||||
|
- Pagination logic
|
||||||
|
- Library selection state
|
||||||
|
- Book viewing interactions
|
||||||
|
|
||||||
|
**4.3 Device Management**
|
||||||
|
- Convert from `devices.templ`
|
||||||
|
- Token regeneration
|
||||||
|
- Sync URL display
|
||||||
|
- Device registration
|
||||||
|
|
||||||
|
**Deliverable:** Collections, bookshelf, and devices features in TypeScript
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/ts/features/collections/rules.ts`
|
||||||
|
- Create: `web/ts/features/collections/bulk.ts`
|
||||||
|
- Create: `web/ts/features/collections/types.ts`
|
||||||
|
- Create: `web/ts/features/bookshelf/display.ts`
|
||||||
|
- Create: `web/ts/features/bookshelf/pagination.ts`
|
||||||
|
- Create: `web/ts/features/devices/token.ts`
|
||||||
|
- Create: `web/ts/features/devices/types.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: High Complexity Features (Linking, Bulk Operations)
|
||||||
|
**Priority: Low | Effort: 5-6 days | Dependencies: Phase 4**
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
**5.1 Book Linking**
|
||||||
|
- Convert matching logic from `unlinked_books.templ`
|
||||||
|
- Search and match functionality
|
||||||
|
- Manual link modal
|
||||||
|
- Bulk auto-link
|
||||||
|
- Bulk get suggestions
|
||||||
|
|
||||||
|
**5.2 API Explorer**
|
||||||
|
- Convert from `api_explorer.templ`
|
||||||
|
- Request/response display
|
||||||
|
- cURL generation
|
||||||
|
- History tracking
|
||||||
|
|
||||||
|
**Deliverable:** Book linking and API explorer in TypeScript
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/ts/features/linking/matcher.ts`
|
||||||
|
- Create: `web/ts/features/linking/bulk-link.ts`
|
||||||
|
- Create: `web/ts/features/linking/manual-link.ts`
|
||||||
|
- Create: `web/ts/features/linking/types.ts`
|
||||||
|
- Create: `web/ts/features/api-explorer/request.ts`
|
||||||
|
- Create: `web/ts/features/api-explorer/response.ts`
|
||||||
|
- Create: `web/ts/features/api-explorer/curl.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Template Integration & Cleanup
|
||||||
|
**Priority: High | Effort: 2-3 days | Dependencies: All phases**
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
|
||||||
|
**6.1 Update Templates**
|
||||||
|
- Replace `<script>` blocks with `<script src="/static/[feature].js">`
|
||||||
|
- Remove inline `onclick="..."` handlers
|
||||||
|
- Add `data-action` attributes for event delegation
|
||||||
|
- Ensure progressive enhancement (pages work without JS)
|
||||||
|
|
||||||
|
**6.2 Verify Progressive Enhancement**
|
||||||
|
- Test all pages with JavaScript disabled
|
||||||
|
- Ensure forms submit with full page reload
|
||||||
|
- Verify critical paths work without JS
|
||||||
|
|
||||||
|
**6.3 Build Configuration**
|
||||||
|
- Update `tsconfig.json` with new file structure
|
||||||
|
- Configure build process for all modules
|
||||||
|
- Add source maps for debugging
|
||||||
|
|
||||||
|
**6.4 Testing**
|
||||||
|
- Verify all features work with TypeScript
|
||||||
|
- Check keyboard navigation
|
||||||
|
- Test form submissions
|
||||||
|
- Validate error handling
|
||||||
|
|
||||||
|
**Deliverable:** Clean templates, all inline JavaScript replaced with TypeScript modules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### 1. Procedural/Imperative with Functional Techniques
|
||||||
|
|
||||||
|
**✅ GOOD:**
|
||||||
|
```typescript
|
||||||
|
// Pure function, no classes
|
||||||
|
function createRuleItem(rule: Rule): HTMLElement {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'rule-item';
|
||||||
|
div.innerHTML = renderRuleHTML(rule);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event delegation (one listener)
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const deleteBtn = target.closest('[data-action="delete-rule"]');
|
||||||
|
if (deleteBtn) {
|
||||||
|
const ruleId = deleteBtn.dataset.ruleId;
|
||||||
|
deleteRule(ruleId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ BAD:**
|
||||||
|
```typescript
|
||||||
|
// Class-based, OOP
|
||||||
|
class RuleManager {
|
||||||
|
private rules: Rule[] = [];
|
||||||
|
constructor() { ... }
|
||||||
|
addRule(rule: Rule) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Type Definitions
|
||||||
|
|
||||||
|
**Reuse Go Handler Types:**
|
||||||
|
```typescript
|
||||||
|
// From internal/handlers/book_matching.go
|
||||||
|
interface UnlinkedBookData {
|
||||||
|
ProgressID: string;
|
||||||
|
DeviceID: string;
|
||||||
|
TitleFromDevice: string;
|
||||||
|
SHA256: string;
|
||||||
|
PotentialMatches: PotentialMatchData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PotentialMatchData {
|
||||||
|
MediaItemID: string;
|
||||||
|
Title: string;
|
||||||
|
Author: string;
|
||||||
|
Confidence: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Only Create Frontend-Specific Types When Necessary:**
|
||||||
|
```typescript
|
||||||
|
// OK: Frontend-specific state
|
||||||
|
type SearchState = {
|
||||||
|
query: string;
|
||||||
|
results: MediaItem[];
|
||||||
|
selectedIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// OK: UI-specific config
|
||||||
|
type ToastType = 'error' | 'success' | 'info';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
|
||||||
|
**Integrate with Toast System:**
|
||||||
|
```typescript
|
||||||
|
async function deleteRule(ruleId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/api/collections/rules/${ruleId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
window.showToast.error(error.message || 'Failed to delete rule');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showToast.success('Rule deleted');
|
||||||
|
removeRuleFromDOM(ruleId);
|
||||||
|
} catch (error) {
|
||||||
|
window.showToast.error('Network error: Unable to connect to server');
|
||||||
|
console.error('Delete rule error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. API Client Pattern
|
||||||
|
|
||||||
|
**Centralized API Client:**
|
||||||
|
```typescript
|
||||||
|
// web/ts/core/api.ts
|
||||||
|
class APIClient {
|
||||||
|
private baseURL = '/api';
|
||||||
|
private getAuthHeader(): string {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
return token ? `Bearer ${token}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(url: string): Promise<Response> {
|
||||||
|
return fetch(`${this.baseURL}${url}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.getAuthHeader(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(url: string, data: unknown): Promise<Response> {
|
||||||
|
return fetch(`${this.baseURL}${url}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.getAuthHeader(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(url: string): Promise<Response> {
|
||||||
|
return fetch(`${this.baseURL}${url}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.getAuthHeader()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new APIClient();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Event Delegation Pattern
|
||||||
|
|
||||||
|
**Replace Inline Handlers:**
|
||||||
|
```html
|
||||||
|
<!-- ❌ DON'T: Inline onclick -->
|
||||||
|
<button onclick="deleteRule('{rule.id}')">Delete</button>
|
||||||
|
|
||||||
|
<!-- ✅ DO: Data attributes -->
|
||||||
|
<button data-action="delete-rule" data-rule-id="{rule.id}">Delete</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handle with Event Delegation:**
|
||||||
|
```typescript
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const deleteBtn = target.closest('[data-action="delete-rule"]');
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
const ruleId = deleteBtn.dataset.ruleId;
|
||||||
|
if (ruleId && confirm('Are you sure you want to delete this rule?')) {
|
||||||
|
await deleteRule(ruleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Progressive Enhancement
|
||||||
|
|
||||||
|
**Ensure Works Without JavaScript:**
|
||||||
|
```html
|
||||||
|
<!-- Form submits with full page reload if JS fails -->
|
||||||
|
<form action="/collections/{id}/rules" method="POST">
|
||||||
|
<input type="hidden" name="field" value="title">
|
||||||
|
<input type="hidden" name="operator" value="contains">
|
||||||
|
<input type="text" name="value" required>
|
||||||
|
|
||||||
|
<button type="submit">Add Rule</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- TypeScript enhances with AJAX submit -->
|
||||||
|
<script src="/static/features/collections/rules.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Deployment
|
||||||
|
|
||||||
|
### TypeScript Configuration
|
||||||
|
|
||||||
|
**Update `tsconfig.json`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "none",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"outDir": "./web/static",
|
||||||
|
"rootDir": "./web/ts",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"web/ts/**/*.ts",
|
||||||
|
"web/src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Scripts
|
||||||
|
|
||||||
|
**Update `package.json`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
|
||||||
|
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
|
||||||
|
"build:ts": "tsc",
|
||||||
|
"build:ts:watch": "tsc --watch",
|
||||||
|
"build:ts:prod": "tsc --sourceMap false",
|
||||||
|
"build:all": "npm run build:ts && npm run build:css:prod",
|
||||||
|
"dev": "npm run build:ts:watch & npm run build:css"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Structure
|
||||||
|
|
||||||
|
**Compiled JavaScript:**
|
||||||
|
```
|
||||||
|
web/static/
|
||||||
|
├── core/
|
||||||
|
│ ├── toast.js # ✅ Already exists
|
||||||
|
│ ├── theme.js # ✅ Already exists
|
||||||
|
│ ├── header.js # ✅ Already exists
|
||||||
|
│ ├── device-management.js # ✅ Already exists
|
||||||
|
│ ├── storage.js # NEW
|
||||||
|
│ ├── dom.js # NEW
|
||||||
|
│ └── api.js # NEW
|
||||||
|
├── features/
|
||||||
|
│ ├── search/
|
||||||
|
│ │ └── search.js # NEW
|
||||||
|
│ ├── collections/
|
||||||
|
│ │ ├── rules.js # NEW
|
||||||
|
│ │ └── bulk.js # NEW
|
||||||
|
│ ├── linking/
|
||||||
|
│ │ ├── matcher.js # NEW
|
||||||
|
│ │ ├── bulk-link.js # NEW
|
||||||
|
│ │ └── manual-link.js # NEW
|
||||||
|
│ ├── bookshelf/
|
||||||
|
│ │ ├── display.js # NEW
|
||||||
|
│ │ └── pagination.js # NEW
|
||||||
|
│ ├── devices/
|
||||||
|
│ │ └── token.js # NEW
|
||||||
|
│ ├── api-explorer/
|
||||||
|
│ │ ├── request.js # NEW
|
||||||
|
│ │ ├── response.js # NEW
|
||||||
|
│ │ └── curl.js # NEW
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── scan.js # NEW
|
||||||
|
│ └── stats.js # NEW
|
||||||
|
└── shared/
|
||||||
|
├── events.js # NEW
|
||||||
|
└── auth.js # NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### 1. Unit Testing (Optional)
|
||||||
|
Test pure functions that don't depend on DOM:
|
||||||
|
```typescript
|
||||||
|
// Test utilities
|
||||||
|
describe('escapeHtml', () => {
|
||||||
|
it('should escape HTML entities', () => {
|
||||||
|
expect(escapeHtml('<script>')).to.equal('<script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test formatters
|
||||||
|
describe('formatConfidence', () => {
|
||||||
|
it('should format confidence as percentage', () => {
|
||||||
|
expect(formatConfidence(0.95)).to.equal('95%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Integration Testing
|
||||||
|
Test API interactions:
|
||||||
|
```typescript
|
||||||
|
describe('deleteRule', () => {
|
||||||
|
it('should call DELETE /api/collections/rules/{id}', async () => {
|
||||||
|
const fetchSpy = sinon.stub(global, 'fetch');
|
||||||
|
await deleteRule('rule-123');
|
||||||
|
|
||||||
|
expect(fetchSpy.calledWith('/api/collections/rules/rule-123', {
|
||||||
|
method: 'DELETE'
|
||||||
|
})).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Manual Testing Checklist
|
||||||
|
- [ ] All CRUD operations work (create, read, update, delete)
|
||||||
|
- [ ] Toast notifications display correctly
|
||||||
|
- [ ] Error handling works (network errors, 500 errors, 404 errors)
|
||||||
|
- [ ] Keyboard navigation works (search, modals)
|
||||||
|
- [ ] Forms submit correctly
|
||||||
|
- [ ] Bulk operations work
|
||||||
|
- [ ] Progressive enhancement (test with JS disabled)
|
||||||
|
- [ ] All pages load with SSR data
|
||||||
|
- [ ] No console errors
|
||||||
|
|
||||||
|
### 4. Browser Testing
|
||||||
|
Test in:
|
||||||
|
- Chrome/Edge (Chromium)
|
||||||
|
- Firefox
|
||||||
|
- Safari (if available)
|
||||||
|
- Mobile browsers (responsive testing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progressive Enhancement Verification
|
||||||
|
|
||||||
|
### Test Without JavaScript
|
||||||
|
|
||||||
|
**Disable JavaScript in browser:**
|
||||||
|
1. Open DevTools
|
||||||
|
2. Settings → Disable JavaScript
|
||||||
|
3. Reload page
|
||||||
|
4. Verify critical functionality works
|
||||||
|
|
||||||
|
**What Should Work:**
|
||||||
|
- ✅ Page loads with SSR data
|
||||||
|
- ✅ Navigation links work (full page reload)
|
||||||
|
- ✅ Forms submit (full page reload, not AJAX)
|
||||||
|
- ✅ All data visible on initial load
|
||||||
|
|
||||||
|
**What Won't Work (Expected):**
|
||||||
|
- ❌ Dynamic updates without page reload
|
||||||
|
- ❌ Toast notifications
|
||||||
|
- ❌ Keyboard navigation
|
||||||
|
- ❌ Modal dialogs
|
||||||
|
- ❌ Auto-complete search
|
||||||
|
|
||||||
|
**Mitigation:**
|
||||||
|
- Ensure forms have proper `action` and `method` attributes
|
||||||
|
- Provide submit buttons for all operations
|
||||||
|
- Include helpful error messages in HTML responses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Mitigation
|
||||||
|
|
||||||
|
### Potential Issues
|
||||||
|
|
||||||
|
**1. Breaking Changes During Conversion**
|
||||||
|
- **Risk**: Converting inline JS breaks functionality
|
||||||
|
- **Mitigation**: Convert incrementally, test each feature
|
||||||
|
- **Rollback**: Keep inline JS until TypeScript is verified
|
||||||
|
|
||||||
|
**2. Type Mismatches with Go Handlers**
|
||||||
|
- **Risk**: TypeScript types don't match JSON responses
|
||||||
|
- **Mitigation**: Use Go handler types as source of truth
|
||||||
|
- **Validation**: Test API responses match TypeScript types
|
||||||
|
|
||||||
|
**3. Progressive Enhancement Regression**
|
||||||
|
- **Risk**: Pages require JavaScript to function
|
||||||
|
- **Mitigation**: Test with JS disabled before/after
|
||||||
|
- **Validation**: Forms must have action/method attributes
|
||||||
|
|
||||||
|
**4. Build Complexity**
|
||||||
|
- **Risk**: TypeScript compilation becomes bottleneck
|
||||||
|
- **Mitigation**: Use existing `tsc` setup, add watch mode
|
||||||
|
- **Validation**: Build time under 5 seconds
|
||||||
|
|
||||||
|
**5. Bundle Size**
|
||||||
|
- **Risk**: Too much JavaScript loaded
|
||||||
|
- **Mitigation**: Split by feature, load per-page
|
||||||
|
- **Validation**: Monitor bundle sizes, code splitting
|
||||||
|
|
||||||
|
### Rollback Strategy
|
||||||
|
|
||||||
|
**If conversion fails:**
|
||||||
|
1. Git revert TypeScript files
|
||||||
|
2. Inline JavaScript still works (wasn't deleted yet)
|
||||||
|
3. Fix issues and retry
|
||||||
|
4. Delete inline JS only after TypeScript verified
|
||||||
|
|
||||||
|
**Per-feature rollback:**
|
||||||
|
```bash
|
||||||
|
# Before converting feature
|
||||||
|
git branch backup-before-collections
|
||||||
|
|
||||||
|
# If collections conversion breaks
|
||||||
|
git checkout backup-before-collections -- templates/collections.templ
|
||||||
|
|
||||||
|
# Fix TypeScript and try again
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Timeline
|
||||||
|
|
||||||
|
| Phase | Duration | Dependencies | Deliverables |
|
||||||
|
|-------|----------|--------------|--------------|
|
||||||
|
| Phase 1: Standalone Conversion | 1 day | None | Search converted to TS |
|
||||||
|
| Phase 2: Core Utilities | 1-2 days | None | Storage, DOM, API, events modules |
|
||||||
|
| Phase 3: Low Complexity | 3-4 days | Phase 2 | Search, header, admin in TS |
|
||||||
|
| Phase 4: Medium Complexity | 4-5 days | Phase 2, 3 | Collections, bookshelf, devices in TS |
|
||||||
|
| Phase 5: High Complexity | 5-6 days | Phase 4 | Linking, API explorer in TS |
|
||||||
|
| Phase 6: Integration & Cleanup | 2-3 days | All phases | Clean templates, verified functionality |
|
||||||
|
| **Total** | **16-21 days** | | **Full TypeScript conversion** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] All inline JavaScript removed from templates
|
||||||
|
- [ ] All features work with TypeScript
|
||||||
|
- [ ] TypeScript compilation passes with strict mode
|
||||||
|
- [ ] Progressive enhancement maintained (pages work without JS)
|
||||||
|
- [ ] No type `any` (except for legacy API responses)
|
||||||
|
- [ ] Event delegation replaces inline handlers
|
||||||
|
- [ ] Build process integrated into npm scripts
|
||||||
|
- [ ] Full manual testing completed
|
||||||
|
- [ ] No regression in functionality
|
||||||
|
- [ ] Bundle sizes monitored and optimized
|
||||||
|
- [ ] SSR still works for all initial page loads
|
||||||
|
- [ ] All CRUD operations use existing `/api/*` endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Review and approve** this plan
|
||||||
|
2. **Setup Phase 1**: Convert `search.js` → TypeScript
|
||||||
|
3. **Begin Phase 2**: Create core utility modules
|
||||||
|
4. **Iterate** through phases 3-5 (feature conversion)
|
||||||
|
5. **Phase 6**: Final integration and cleanup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Template Integration Examples
|
||||||
|
|
||||||
|
### Before: Inline JavaScript
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- templates/collection_rules.templ -->
|
||||||
|
<script>
|
||||||
|
let collectionId = '{ collection.ID }';
|
||||||
|
|
||||||
|
function renderRules(rules) {
|
||||||
|
const container = document.getElementById('rules-container');
|
||||||
|
container.innerHTML = rules.map((rule, index) => `
|
||||||
|
<div class="rule-item">
|
||||||
|
<span>${rule.field}</span>
|
||||||
|
<button onclick="deleteRule('${rule.id}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRule(ruleId) {
|
||||||
|
fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
|
||||||
|
}).then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
loadRules();
|
||||||
|
showToast('Rule deleted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRules() {
|
||||||
|
fetch(`/api/collections/${collectionId}/rules`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(rules => renderRules(rules));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRules();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### After: TypeScript Module + Event Delegation
|
||||||
|
|
||||||
|
**TypeScript Module:**
|
||||||
|
```typescript
|
||||||
|
// web/ts/features/collections/rules.ts
|
||||||
|
import { apiClient } from '../../core/api.js';
|
||||||
|
import { escapeHtml } from '../../core/dom.js';
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
id: string;
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRules(rules: Rule[]): void {
|
||||||
|
const container = document.getElementById('rules-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = rules.map(rule => `
|
||||||
|
<div class="rule-item" data-rule-id="${rule.id}">
|
||||||
|
<span>${escapeHtml(rule.field)}</span>
|
||||||
|
<button data-action="delete-rule" data-rule-id="${rule.id}">Delete</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRules(collectionId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/collections/${collectionId}/rules`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load rules');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: Rule[] = await response.json();
|
||||||
|
renderRules(rules);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load rules error:', error);
|
||||||
|
window.showToast.error('Failed to load rules');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
|
||||||
|
if (!confirm('Are you sure you want to delete this rule?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/collections/${collectionId}/rules/${ruleId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete rule');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showToast.success('Rule deleted');
|
||||||
|
|
||||||
|
// Remove from DOM
|
||||||
|
const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`);
|
||||||
|
ruleElement?.remove();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete rule error:', error);
|
||||||
|
window.showToast.error('Failed to delete rule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event delegation for delete buttons
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const deleteBtn = target.closest('[data-action="delete-rule"]');
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
const ruleId = deleteBtn.dataset.ruleId;
|
||||||
|
const collectionId = deleteBtn.dataset.collectionId;
|
||||||
|
|
||||||
|
if (ruleId && collectionId) {
|
||||||
|
deleteRule(collectionId, ruleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const collectionId = document.body.dataset.collectionId;
|
||||||
|
if (collectionId) {
|
||||||
|
loadRules(collectionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cleaned Template:**
|
||||||
|
```html
|
||||||
|
<!-- templates/collection_rules.templ -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/core/toast.js"></script>
|
||||||
|
<script src="/static/core/api.js"></script>
|
||||||
|
<script src="/static/features/collections/rules.js"></script>
|
||||||
|
</head>
|
||||||
|
<body data-collection-id="{ collection.ID }">
|
||||||
|
<div id="rules-container">
|
||||||
|
<!-- SSR: Initial rules rendered server-side -->
|
||||||
|
{ range .Rules }
|
||||||
|
<div class="rule-item" data-rule-id="{ .ID }">
|
||||||
|
<span>{ .Field }</span>
|
||||||
|
<button data-action="delete-rule"
|
||||||
|
data-rule-id="{ .ID }"
|
||||||
|
data-collection-id="{ collection.ID }">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{ end }
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ No inline JavaScript
|
||||||
|
- ✅ Event delegation (one listener)
|
||||||
|
- ✅ Type-safe API calls
|
||||||
|
- ✅ Better error handling
|
||||||
|
- ✅ Reusable module
|
||||||
|
- ✅ SSR still works (initial rules in HTML)
|
||||||
|
- ✅ Progressive enhancement (form can submit without JS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated: 2025-02-17*
|
||||||
|
*Follows: PROJECT_GUIDELINES.md*
|
||||||
|
*Architecture: Hybrid SSR + TypeScript CRUD*
|
||||||
Reference in New Issue
Block a user