Files
bookhoard/ALPINE_COMPLETION_GUIDE.md
T
john-okeefe e9e568e67e docs: Add comprehensive Alpine.js integration completion guide
Created a detailed 1,354-line migration guide to complete the Alpine.js
integration from the current hybrid state (manual DOM manipulation) to
full reactive Alpine.js.

Document contents:
- Current state analysis (121 manual DOM manipulations identified)
- Complete migration strategy with 4-step pattern
- Phase-by-phase implementation guide (header.templ reference + 7 modals)
- Before/after code examples with line numbers
- Alpine.store pattern for global modal state
- Verification checklists and testing procedures
- Troubleshooting guide for common issues
- Success criteria and metrics

Key benefits documented:
- Eliminates 121 instances of manual DOM manipulation
- Reduces header.ts from 100 to 40 lines (60% reduction)
- Adds smooth transitions with x-transition
- Implements click-outside detection with @click.outside
- Provides clean, maintainable architecture

This guide completes the ESBUILD_MIGRATION_PLAN.md Phase 3 (Template
Migration) with actionable steps for any developer to finish the
integration in 10-12 hours.

Related: ESBUILD_MIGRATION_PLAN.md Phase 3, lines 998-1242
2026-03-09 20:11:36 -04:00

1355 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Alpine.js Integration Completion Guide
## Executive Summary
This guide completes the migration from **hybrid onclick/@click with manual DOM manipulation** to **full reactive Alpine.js** with state-driven UI.
**Current State**: Hybrid approach with 121 manual DOM manipulations
**Target State**: Full reactive Alpine.js with zero manual DOM manipulation
**Estimated Time**: 10-12 hours
**Impact**: Cleaner code, better maintainability, smoother UX
---
## Table of Contents
1. [Current State Analysis](#current-state-analysis)
2. [Migration Strategy](#migration-strategy)
3. [Phase 1: Header Template (Reference Implementation)](#phase-1-header-template-reference-implementation)
4. [Phase 2: Modal Templates](#phase-2-modal-templates)
5. [Phase 3: Verification & Testing](#phase-3-verification--testing)
6. [Phase 4: Cleanup](#phase-4-cleanup)
7. [Troubleshooting](#troubleshooting)
8. [Success Criteria](#success-criteria)
---
## Current State Analysis
### What's Already Done ✅
- All `onclick` handlers converted to `@click` directives
- Functions registered with `Alpine.global()` in TypeScript
- 18 templates have `x-data="namespace"` attributes
- HTMX integration working for forms
### What's Still Missing ❌
**Problem**: 121 instances of manual DOM manipulation in TypeScript files
**Example from header.ts:7-17:**
```typescript
const toggleThemeDropdown = (): void => {
const dropdown = document.getElementById("theme-dropdown");
if (dropdown) {
dropdown.classList.toggle("hidden"); // ← Manual DOM manipulation!
const userMenu = document.getElementById("user-menu");
if (userMenu && !dropdown.classList.contains("hidden")) {
userMenu.classList.add("hidden"); // ← Manual DOM manipulation!
}
}
};
```
**Templates still using:**
- `id="theme-dropdown"` + `class="hidden"` for show/hide
- No reactive state variables
- No `x-show` directives
- No `@click.outside` for closing dropdowns
- No `x-transition` for animations
### What Needs Migration
**8 stateful templates** (modals, dropdowns, wizards):
1.**header.templ** - Theme dropdown + user menu (P0 - used in 17 places)
2.**collection_modal.templ** - Create/edit collection modal
3.**collections.templ** - Add books modal + navigation
4.**conflicts.templ** - Conflict resolution modal
5.**queue.templ** - Queue actions modal
6.**admin.templ** - Scan progress modal
7.**devices.templ** - Device token modal
8.**profile_modal.templ** - Profile edit modal
**Note**: Simple buttons with `@click` handlers are fine - no migration needed.
---
## Migration Strategy
### The Pattern
Every migration follows the same 4-step pattern:
1. **Template Changes**: Add `x-data` state, replace `class="hidden"` with `x-show`, add transitions
2. **TypeScript Cleanup**: Remove manual DOM manipulation functions
3. **Alpine Registration**: Remove deleted functions from `Alpine.global()`
4. **Testing**: Verify functionality, build, check for regressions
### Key Principles
- **State lives in template** (`x-data="{ open: false }"`)
- **UI updates automatically** (`x-show="open"`)
- **No manual DOM manipulation** in TypeScript
- **Pure business logic only** in TypeScript functions
---
## Phase 1: Header Template (Reference Implementation)
**Priority**: P0 (highest - used in 17 templates)
**Time**: 2-3 hours
**Complexity**: High (2 dropdowns + theme switching + click-outside)
### Step 1.1: Update header.templ
**Location**: `templates/header.templ`
**Lines to modify**: 46-191
**Current Structure (lines 46-53):**
```templ
<div class="relative">
<button @click="toggleThemeDropdown()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
</button>
<div id="theme-dropdown" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
```
**New Structure:**
```templ
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }">
<!-- Theme Switcher -->
<div class="relative">
<button @click="themeDropdownOpen = !themeDropdownOpen"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
</button>
<div x-show="themeDropdownOpen"
@click.outside="themeDropdownOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
```
**Key Changes:**
1. ✅ Wrapped both dropdowns in single `x-data` container (line 1)
2. ✅ Replaced `@click="toggleThemeDropdown()"` with `@click="themeDropdownOpen = !themeDropdownOpen"` (line 4)
3. ✅ Replaced `id="theme-dropdown" class="hidden"` with `x-show="themeDropdownOpen"` (line 13)
4. ✅ Added `@click.outside="themeDropdownOpen = false"` (line 14)
5. ✅ Added `x-transition` directives for smooth animations (lines 15-20)
6. ✅ Added `style="display: none;"` to prevent flash of unstyled content (line 22)
**Update theme buttons (lines 56, 60, 64, 68, 72, 76, 80):**
Replace:
```templ
<button @click="changeThemeTo('tokyo-night')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
```
With:
```templ
<button @click="header.changeThemeTo('tokyo-night'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
```
**Key Changes:**
1. ✅ Namespace added: `header.changeThemeTo('tokyo-night')`
2. ✅ Close dropdown after selection: `themeDropdownOpen = false`
**Update wood paneling buttons (lines 87, 96, 108, 120):**
Replace:
```templ
<button @click="changeWoodPaneling('none')" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary);" data-wood="none">
```
With:
```templ
<button @click="woodPaneling.change('none'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none">
```
**Note**: We'll need to add `woodPaneling` namespace in Alpine registration (Step 1.3).
**Update user menu section (lines 136-160):**
Replace:
```templ
<div class="relative">
<button @click="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
</button>
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
```
With:
```templ
<div class="relative">
<button @click="userMenuOpen = !userMenuOpen"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
</button>
<div x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
```
**Update logout button (line 156):**
Replace:
```templ
<button @click="logout()" class="block w-full text-left px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary);">
Logout
</button>
```
With:
```templ
<button @click="header.logout(); userMenuOpen = false"
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
style="color: var(--text-primary); background-color: var(--bg-secondary);">
Logout
</button>
```
**Complete header.templ changes summary:**
- ✅ Wrapped nav in `x-data="{ themeDropdownOpen: false, userMenuOpen: false }"`
- ✅ Replaced all `@click="toggleXxx()"` with state toggles
- ✅ Replaced `id="xxx"` + `class="hidden"` with `x-show="xxxOpen"`
- ✅ Added `@click.outside` to both dropdowns
- ✅ Added `x-transition` for smooth animations
- ✅ Added namespace calls: `header.changeThemeTo()`, `header.logout()`
- ✅ Close dropdowns after action: `themeDropdownOpen = false`
### Step 1.2: Update header.ts
**Location**: `web/src/header.ts`
**Lines to delete**: 7-31, 64-88
**Delete manual DOM manipulation functions:**
```typescript
// DELETE lines 7-31:
const toggleThemeDropdown = (): void => {
const dropdown = document.getElementById("theme-dropdown");
if (dropdown) {
dropdown.classList.toggle("hidden");
const userMenu = document.getElementById("user-menu");
if (userMenu && !dropdown.classList.contains("hidden")) {
userMenu.classList.add("hidden");
}
}
};
const toggleUserMenu = (): void => {
const menu = document.getElementById("user-menu");
if (menu) {
menu.classList.toggle("hidden");
const themeDropdown = document.getElementById("theme-dropdown");
if (themeDropdown && !menu.classList.contains("hidden")) {
themeDropdown.classList.add("hidden");
}
}
};
```
**Simplify changeThemeTo function (lines 33-55):**
**Before:**
```typescript
const changeThemeTo = (theme: string): void => {
applyTheme(theme);
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// Close dropdown ← Manual DOM manipulation!
const dropdown = document.getElementById("theme-dropdown");
if (dropdown) {
dropdown.classList.add("hidden");
}
};
```
**After:**
```typescript
const changeThemeTo = (theme: string): void => {
// Apply theme
applyTheme(theme);
// Save to server if logged in
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// No dropdown manipulation - Alpine handles it via template state!
};
```
**Delete click-outside event listener (lines 64-88):**
```typescript
// DELETE entire event listener - Alpine's @click.outside handles this:
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const themeDropdown = document.getElementById("theme-dropdown");
const userMenu = document.getElementById("user-menu");
const themeButton = target?.closest(
'button[onclick="toggleThemeDropdown()"]',
);
const userButton = target?.closest('button[onclick="toggleUserMenu()"]');
if (
!themeButton &&
themeDropdown &&
!themeDropdown.classList.contains("hidden")
) {
if (!themeDropdown.contains(target)) {
themeDropdown.classList.add("hidden");
}
}
if (!userButton && userMenu && !userMenu.classList.contains("hidden")) {
if (!userMenu.contains(target)) {
userMenu.classList.add("hidden");
}
}
});
```
**Final header.ts (after cleanup):**
```typescript
// Header functionality
import { Alpine } from "./alpine";
import { applyTheme } from "./theme";
import { updateThemeIndicators } from "./themeDropdown";
const changeThemeTo = (theme: string): void => {
// Apply the theme using the consolidated function from theme.ts
applyTheme(theme);
// Save to server if logged in
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// Alpine closes dropdown automatically via template state
};
const logout = (): void => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/";
};
export { changeThemeTo, logout };
Alpine.global("header", {
logout,
changeThemeTo: (theme: string) => {
changeThemeTo(theme);
updateThemeIndicators();
},
});
```
**Result**: header.ts reduced from **100 lines to 40 lines** (60% reduction)
### Step 1.3: Create woodPaneling.ts Namespace
**Location**: `web/src/woodPaneling.ts`
**Current**: Functions exist but need Alpine registration for namespace calls
**Add to bottom of woodPaneling.ts:**
```typescript
import { Alpine } from "./alpine";
// ... existing functions ...
Alpine.global("woodPaneling", {
change: changeWoodPaneling,
});
```
### Step 1.4: Verify header.templ Migration
**Build verification:**
```bash
npm run build:ts
templ generate
```
**Expected**: No errors, templates compile successfully
**Manual testing:**
1. Start application: `go run .`
2. Navigate to any page with header (all pages)
3. Test theme dropdown:
- [ ] Click theme button → dropdown opens with smooth transition
- [ ] Click outside → dropdown closes
- [ ] Click theme option → theme changes, dropdown closes
- [ ] User menu closes if open
4. Test user menu:
- [ ] Click user button → menu opens with smooth transition
- [ ] Click outside → menu closes
- [ ] Click logout → logout, menu closes
- [ ] Theme dropdown closes if open
5. Test wood paneling:
- [ ] Click wood paneling option → changes, dropdown closes
**Debug with Alpine DevTools (optional):**
```bash
# Install Alpine DevTools browser extension
# Open DevTools → Alpine tab
# Inspect reactive state: themeDropdownOpen, userMenuOpen
```
**Common issues:**
- ❌ Dropdown doesn't open: Check if `x-data` is on parent container
- ❌ Dropdown doesn't close: Check if `@click.outside` is on dropdown div
- ❌ No smooth transition: Check if `x-transition` directives are present
- ❌ Flash of unstyled content: Verify `style="display: none;"` is on x-show elements
---
## Phase 2: Modal Templates
All modal templates follow the same pattern. Apply consistently.
### 2.1: collection_modal.templ
**Location**: `templates/collection_modal.templ`
**Time**: 1 hour
**Complexity**: Medium (modal with color/icon pickers)
**Template Changes:**
**Current (line 4):**
```templ
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
```
**No change needed** - `x-data="collections"` namespace is correct, but we need local state.
**Add local state to wrapper:**
Replace line 4:
```templ
<div x-data="{ colorModalOpen: false }" x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
```
**Better approach - use Alpine.data():**
**In web/src/collections.ts, add:**
```typescript
Alpine.data("collections", () => ({
colorModalOpen: false,
iconModalOpen: false,
openColorModal() {
this.colorModalOpen = true;
},
closeColorModal() {
this.colorModalOpen = false;
},
// ... existing methods ...
}));
```
**Actually, for modals, the simplest approach:**
Since this is a modal that's loaded via HTMX, we don't need x-data state. The modal itself is shown/hidden by HTMX.
**Skip this template** - it's already using Alpine namespace correctly via HTMX loading.
### 2.2: collections.templ
**Location**: `templates/collections.templ`
**Time**: 1-2 hours
**Complexity**: Medium (add books modal)
**Find "add-books-modal" section (lines 227-260):**
**Current:**
```templ
<div id="add-books-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button @click="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div>
```
**Replace with:**
```templ
<div x-data="{ addBooksModalOpen: false }">
<!-- Button to open modal (line 156) -->
<button @click="addBooksModalOpen = true" class="btn-primary px-4 py-2 rounded-lg">
Add Books
</button>
<!-- Modal (line 227) -->
<div x-show="addBooksModalOpen"
x-transition
@click.self="addBooksModalOpen = false"
class="fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
<div @click.stop
class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button @click="addBooksModalOpen = false"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-primary)"></button>
</div>
```
**Note**: The modal needs to be wrapped in x-data container that includes the button. Since the button and modal are far apart, we need to restructure.
**Better approach - use Alpine.store():**
**In web/src/collections.ts, add:**
```typescript
// Create a global store for modal state
Alpine.store('modals', {
addBooks: false,
showAddBooks() {
this.addBooks = true;
},
hideAddBooks() {
this.addBooks = false;
}
});
```
**In template, use:**
```templ
<!-- Button (line 156) -->
<button @click="$store.modals.showAddBooks()" class="btn-primary px-4 py-2 rounded-lg">
Add Books
</button>
<!-- Modal (line 227) -->
<div x-show="$store.modals.addBooks"
x-transition
@click.self="$store.modals.hideAddBooks()"
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
```
**Remove from TypeScript:**
In `web/src/collections.ts`, delete:
```typescript
function showAddBooksModal() { /* ... */ }
function hideAddBooksModal() { /* ... */ }
```
**Keep only business logic:**
```typescript
async function addSelectedBooks() { /* API call */ }
async function searchBooksForCollections() { /* API call */ }
```
### 2.3: conflicts.templ
**Location**: `templates/conflicts.templ`
**Time**: 1 hour
**Complexity**: Medium (resolution modal)
**Use Alpine.store pattern (same as collections):**
**In web/src/conflicts.ts, add:**
```typescript
Alpine.store('modals', {
resolveConflict: false,
showResolveConflict() {
this.resolveConflict = true;
},
hideResolveConflict() {
this.resolveConflict = false;
}
});
```
**Template changes:**
**Current (line 123):**
```templ
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
```
**Replace with:**
```templ
<div x-show="$store.modals.resolveConflict"
x-transition
@click.self="$store.modals.hideResolveConflict()"
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
```
**Update button (line 110):**
Replace:
```templ
<button @click="showConflictModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
```
With:
```templ
<button @click="conflicts.showResolve('{ conflict.ID }'); $store.modals.showResolveConflict()"
class="btn-primary px-4 py-2 rounded-lg">
```
**Remove from TypeScript:**
Delete `showConflictModal()`, `hideConflictModal()` functions.
### 2.4: queue.templ
**Location**: `templates/queue.templ`
**Time**: 1 hour
**Complexity**: Low (simple action modals)
**Use Alpine.store pattern** - same as conflicts.templ
### 2.5: admin.templ
**Location**: `templates/admin.templ`
**Time**: 1 hour
**Complexity**: Medium (scan progress modal)
**Use Alpine.store pattern** - same as collections.templ
### 2.6: devices.templ
**Location**: `templates/devices.templ`
**Time**: 1 hour
**Complexity**: Low (token generation modal)
**Use Alpine.store pattern** - same as conflicts.templ
### 2.7: profile_modal.templ
**Location**: `templates/profile_modal.templ`
**Time**: 1 hour
**Complexity**: Low (edit profile modal)
**Use Alpine.store pattern** - same as conflicts.templ
---
## Phase 3: Verification & Testing
### For Each Migrated Template
#### Build Verification
```bash
# 1. Build TypeScript
npm run build:ts
# 2. Generate templates
templ generate
# 3. Check for errors
# Expected: "Complete" with 0 errors
```
#### Manual Testing Checklist
**For header.templ:**
- [ ] Theme dropdown opens with smooth transition
- [ ] Theme dropdown closes when clicking outside
- [ ] Theme changes when option clicked
- [ ] Theme dropdown closes after selection
- [ ] User menu opens with smooth transition
- [ ] User menu closes when clicking outside
- [ ] Logout works and closes menu
- [ ] Wood paneling changes work
- [ ] Both dropdowns don't open simultaneously (mutual exclusion)
**For each modal template:**
- [ ] Modal opens with smooth transition
- [ ] Modal closes when clicking outside
- [ ] Modal closes when clicking X button
- [ ] Modal closes when clicking Cancel button
- [ ] Form submission works (HTMX)
- [ ] No console errors
#### Regression Testing
After each template migration:
```bash
# 1. Start application
go run .
# 2. Test all pages
- [ ] Dashboard loads
- [ ] Library loads
- [ ] Collections loads
- [ ] Devices loads
- [ ] Admin loads (if admin)
# 3. Test HTMX functionality
- [ ] Forms submit correctly
- [ ] Toast notifications appear
- [ ] Page updates work
# 4. Check browser console
- [ ] No JavaScript errors
- [ ] No 404s for .js files
- [ ] Alpine is loaded (check: window.Alpine in console)
```
---
## Phase 4: Cleanup
### 4.1: Remove Unused Functions
After all templates are migrated, search for remaining manual DOM manipulation:
```bash
cd /home/nymusicman/Code/bookhoard/web/src
# Find remaining classList manipulation
grep -rn "classList.toggle\|classList.add\|classList.remove" *.ts
# Find remaining getElementById for show/hide
grep -rn "getElementById.*dropdown\|getElementById.*modal" *.ts
# Find remaining event listeners for click-outside
grep -rn "addEventListener.*click" *.ts
```
**Expected**: Only 0-5 instances remaining (legitimate use cases)
### 4.2: Update ESBUILD_MIGRATION_PLAN.md
Add completion note:
```markdown
## Migration Status
### ✅ Completed
- Phase 0: ES Module Exports
- Phase 1: Internal TypeScript Dependencies
- Phase 2: Dual Exports (Alpine Bridge)
- Phase 3: Template Migration (ALL templates)
### 🎯 Final State
- 0 manual DOM manipulations (down from 121)
- All stateful templates using x-data/x-show
- Smooth transitions with x-transition
- Click-outside handling with @click.outside
- 60% less TypeScript code (header.ts: 100 → 40 lines)
```
### 4.3: Create Migration Documentation
Create `docs/contributing/alpinejs-patterns.md`:
```markdown
# Alpine.js Patterns in Bookhoard
## Modal Pattern
For modals loaded via HTMX, use Alpine.store:
```typescript
// In TypeScript
Alpine.store('modals', {
myModal: false,
show() { this.myModal = true; },
hide() { this.myModal = false; }
});
```
```templ
<!-- In template -->
<button @click="$store.modals.show()">Open</button>
<div x-show="$store.modals.myModal"
@click.self="$store.modals.hide()"
x-transition>
<!-- Modal content -->
</div>
```
## Dropdown Pattern
For dropdowns in same component, use local x-data:
```templ
<div x-data="{ dropdownOpen: false }">
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
<div x-show="dropdownOpen"
@click.outside="dropdownOpen = false"
x-transition>
<!-- Dropdown content -->
</div>
</div>
```
## Best Practices
- ✅ State lives in template (x-data)
- ✅ UI updates automatically (x-show)
- ✅ No manual DOM manipulation in TypeScript
- ✅ Pure business logic only in TypeScript functions
- ✅ Use Alpine.store for global state
- ✅ Use local x-data for component state
- ❌ Never use classList in TypeScript
- ❌ Never use getElementById for show/hide
```
---
## Troubleshooting
### Issue: Dropdown doesn't open
**Symptoms**: Click button, nothing happens
**Diagnosis**:
```javascript
// Open browser console
console.log(window.Alpine); // Should be defined
// Check if x-data is present
// Inspect element - should have x-data attribute
```
**Solutions**:
1. Check if `x-data` is on parent container
2. Check if Alpine is loaded: `<script src="/static/main.js"></script>`
3. Check for JavaScript errors in console
4. Verify variable name matches: `x-data="{ open: false }"` and `x-show="open"`
### Issue: Dropdown doesn't close when clicking outside
**Symptoms**: Click outside, dropdown stays open
**Solutions**:
1. Check if `@click.outside` is on dropdown div (not button)
2. Check for z-index conflicts
3. Check if other elements are blocking clicks
### Issue: No smooth transition
**Symptoms**: Dropdown appears instantly without animation
**Solutions**:
1. Check if `x-transition` is present
2. Check if `display: none;` is inline style (prevents FOUC)
3. Check for CSS conflicts (transition properties)
### Issue: Flash of unstyled content (FOUC)
**Symptoms**: Dropdown briefly visible before Alpine loads
**Solutions**:
1. Add inline style: `style="display: none;"`
2. Alpine will override this when it loads
3. Combine with `x-show` for reactive behavior
### Issue: Modal closes when clicking inside
**Symptoms**: Click inside modal, it closes
**Solutions**:
1. Use `@click.self` instead of `@click.outside`
2. Add `@click.stop` on inner elements to stop propagation
3. Check modal structure - ensure backdrop has click handler, not content
### Issue: TypeScript compilation error
**Symptoms**: `npm run build:ts` fails after changes
**Solutions**:
1. Check for undefined functions (removed but still referenced)
2. Check Alpine.global() registration (remove deleted functions)
3. Run `npm run build:ts -- --verbose` for detailed error
---
## Success Criteria
### Phase Completion Checklist
- [ ] All 8 stateful templates migrated
- [ ] All manual DOM manipulation removed (121 → 0 instances)
- [ ] All templates build successfully: `templ generate`
- [ ] TypeScript compiles successfully: `npm run build:ts`
- [ ] All dropdowns work with smooth transitions
- [ ] All modals work with smooth transitions
- [ ] Click-outside behavior works for all dropdowns/modals
- [ ] No console errors on any page
- [ ] No regressions in HTMX functionality
- [ ] All forms submit correctly
- [ ] Alpine DevTools shows reactive state correctly
### Metrics
**Before Migration:**
- 121 manual DOM manipulations
- 100 lines in header.ts
- No reactive state
- No smooth transitions
- Manual click-outside handling
**After Migration:**
- 0 manual DOM manipulations
- 40 lines in header.ts (60% reduction)
- Full reactive state with x-data
- Smooth transitions with x-transition
- Built-in click-outside with @click.outside
### Final Verification
```bash
# 1. Complete build
npm run build:ts
templ generate
go build
# 2. Check for remaining manual DOM manipulation
cd web/src
grep -rn "classList.toggle\|classList.add.*hidden\|classList.remove.*hidden" *.ts | wc -l
# Expected: 0-5 (legitimate use cases only)
# 3. Run application
go run .
# 4. Test all pages
# - Navigate through all major pages
# - Test all dropdowns
# - Test all modals
# - Test all forms
# 5. Check Alpine DevTools
# - Install browser extension
# - Inspect reactive state
# - Verify state changes correctly
```
---
## Appendix: Complete Code Examples
### A.1: Complete header.templ (After Migration)
```templ
package templates
templ Header(user User, currentPath string) {
<nav class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);">
<div class="w-full px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Left: App Title & Navigation -->
<div class="flex items-center space-x-6">
<a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity" style="color: var(--text-primary); text-decoration: none;">
📚 Bookhoard
</a>
<div class="hidden md:flex items-center space-x-4">
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections
</a>
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress
</a>
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices
</a>
</div>
</div>
<!-- Center: Search Box -->
<div class="flex-1 max-w-2xl mx-8">
<div class="relative">
<input
type="text"
id="header-search"
placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
<!-- Right: Theme Switcher & User Menu -->
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }">
<!-- Theme Switcher -->
<div class="relative">
<button @click="themeDropdownOpen = !themeDropdownOpen"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
</button>
<div x-show="themeDropdownOpen"
@click.outside="themeDropdownOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
<div class="p-4">
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
<div class="space-y-2">
<button @click="header.changeThemeTo('tokyo-night'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night
</button>
<button @click="header.changeThemeTo('dracula'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula
</button>
<button @click="header.changeThemeTo('nord'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord
</button>
<button @click="header.changeThemeTo('solarized-dark'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark
</button>
<button @click="header.changeThemeTo('monokai'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai
</button>
<button @click="header.changeThemeTo('one-dark-pro'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro
</button>
<button @click="header.changeThemeTo('material-dark'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark
</button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
<button @click="woodPaneling.change('none'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none">
None
</button>
<button @click="woodPaneling.change('wood-light'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-light">
<span class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
Wood Light
</button>
<button @click="woodPaneling.change('wood-dark'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-dark">
<span class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
Wood Dark
</button>
<button @click="woodPaneling.change('wood-mahogany'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-mahogany">
<span class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
Wood Mahogany
</button>
</div>
</div>
</div>
</div>
</div>
<!-- User Icon with Dropdown -->
<div class="relative">
if user.ID != "" {
<!-- LOGGED IN: Show user menu with logout -->
<button @click="userMenuOpen = !userMenuOpen"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
</button>
<div x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
<div class="py-1">
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Admin Panel
</a>
}
<div class="border-t my-1" style="border-color: var(--border);"></div>
<button @click="header.logout(); userMenuOpen = false"
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
style="color: var(--text-primary); background-color: var(--bg-secondary);">
Logout
</button>
</div>
</div>
} else {
<!-- LOGGED OUT: Show login form -->
<button @click="userMenuOpen = !userMenuOpen"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
</button>
<div x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"/>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In
</button>
</form>
<div id="login-result"></div>
<div class="border-t my-2" style="border-color: var(--border);"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up
</a>
</div>
}
</div>
</div>
</div>
</div>
</nav>
<script src="/static/main.js"></script>
}
```
### A.2: Complete header.ts (After Migration)
```typescript
// Header functionality
import { Alpine } from "./alpine";
import { applyTheme } from "./theme";
import { updateThemeIndicators } from "./themeDropdown";
const changeThemeTo = (theme: string): void => {
// Apply the theme using the consolidated function from theme.ts
applyTheme(theme);
// Save to server if logged in
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// Alpine closes dropdown automatically via template state
};
const logout = (): void => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/";
};
export { changeThemeTo, logout };
Alpine.global("header", {
logout,
changeThemeTo: (theme: string) => {
changeThemeTo(theme);
updateThemeIndicators();
},
});
```
### A.3: Alpine.store Pattern for Modals
**TypeScript (web/src/collections.ts):**
```typescript
import { Alpine } from "./alpine";
// Create global modal store
Alpine.store('modals', {
addBooks: false,
editCollection: false,
showAddBooks() {
this.addBooks = true;
},
hideAddBooks() {
this.addBooks = false;
},
showEditCollection() {
this.editCollection = true;
},
hideEditCollection() {
this.editCollection = false;
}
});
// Business logic functions (no DOM manipulation)
async function addSelectedBooks() {
const token = localStorage.getItem("token");
// ... API call logic ...
}
async function searchBooksForCollections(query: string) {
// ... search logic ...
}
export { addSelectedBooks, searchBooksForCollections };
```
**Template (templates/collections.templ):**
```templ
<!-- Button to open modal -->
<button @click="$store.modals.showAddBooks()" class="btn-primary">
Add Books
</button>
<!-- Modal -->
<div x-show="$store.modals.addBooks"
x-transition
@click.self="$store.modals.hideAddBooks()"
class="fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
<div @click.stop class="card rounded-lg p-6">
<div class="flex justify-between items-center mb-6">
<h2>Add Books to Collection</h2>
<button @click="$store.modals.hideAddBooks()"></button>
</div>
<!-- Modal content -->
<input type="text" placeholder="Search books...">
<button @click="addSelectedBooks()">Add Selected</button>
</div>
</div>
```
---
## Next Steps
1. **Start with header.templ migration** (highest priority, reference implementation)
2. **Apply Alpine.store pattern** to all modal templates
3. **Test thoroughly** after each migration
4. **Clean up unused functions** from TypeScript files
5. **Update documentation** with patterns learned
6. **Verify final state**: 0 manual DOM manipulations
**Estimated completion time**: 10-12 hours
**Success metrics**:
- ✅ All 8 templates migrated
- ✅ 121 manual DOM manipulations → 0
- ✅ All dropdowns/modals use reactive state
- ✅ Smooth transitions throughout
- ✅ Clean, maintainable codebase
Good luck with the migration! 🚀