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:
2026-02-17 21:58:33 -05:00
parent 17ce558522
commit 4d756acff4
+992
View File
@@ -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('&lt;script&gt;');
});
});
// 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*