Updated ALPINE_COMPLETION_GUIDE.md to reference SSR_FIRST_ALPINE_GUIDE.md and clarify the relationship between all three guides. Changes: - Added reference to SSR_FIRST_ALPINE_GUIDE.md as prerequisite - Added Phase 0: Prerequisites (dead export removal) - Added Phase 3: Other Templates (DOMContentLoaded cleanup) - Reorganized Phase numbers (old Phase 3→4, 4→5, 5→6) - Updated Key Principles section to include SSR-first rules - Added "How This Guide Relates to Others" section (4.3) - Updated Next Steps with recommended reading order - Clarified documentation strategy and goals Key SSR-first additions: - ❌ NEVER fetch data in x-init if data is already SSR'd - ✅ x-init ONLY for setup (event listeners, modals) - ✅ Data fetch ONLY after user actions (create/delete/update) Three Guide Strategy: 1. SSR_FIRST_ALPINE_GUIDE.md - Architecture principles (READ FIRST) 2. COLLECTIONS_CLEANUP_GUIDE.md - Quick reference for immediate fixes 3. ALPINE_COMPLETION_GUIDE.md - Full migration path (this guide) This ensures users understand SSR-first architecture before attempting full Alpine.js migration, preventing common mistakes like fetching data in x-init that replaces SSR content. The guides now work together without contradiction: - SSR_FIRST establishes principles - COLLECTIONS_CLEANUP provides quick fix reference - ALPINE_COMPLETION provides complete migration path Eventually COLLECTIONS_CLEANUP_GUIDE.md can be deprecated once all patterns are understood and incorporated into the other two guides.
53 KiB
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, while maintaining SSR-first architecture.
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
References:
SSR_FIRST_ALPINE_GUIDE.md- SSR-first architecture principles (READ THIS FIRST)COLLECTIONS_CLEANUP_GUIDE.md- Immediate console error fixes (quick reference)
Table of Contents
- Current State Analysis
- Migration Strategy
- Phase 0: Prerequisites (Dead Export Removal)
- Phase 1: Header Template (Reference Implementation)
- [Phase 2: Modal Templates]((#phase-2-modal-templates)
- Phase 3: Other Templates (DOMContentLoaded Cleanup)
- [Phase 4: Verification & Testing]((#phase-4-verification--testing)
- [Phase 5: Cleanup]((#phase-5-cleanup)
- Troubleshooting
- Success Criteria
Current State Analysis
Current State Analysis
What's Already Done ✅
- All
onclickhandlers converted to@clickdirectives - Functions registered with
Alpine.global()in TypeScript - 18 templates have
x-data="namespace"attributes - HTMX integration working for forms
- SSR-first architecture documented in
SSR_FIRST_ALPINE_GUIDE.md
Documentation Structure
Three complementary guides:
-
SSR_FIRST_ALPINE_GUIDE.md- READ THIS FIRST- SSR-first architecture principles
- Page type classifications (Type 1: 80% SSR, Type 2: SSR+Interactive, Type 3: 80% JS)
- When to fetch data vs when to use SSR data
- Server-side token injection
- Prerequisite for understanding this guide
-
COLLECTIONS_CLEANUP_GUIDE.md- Quick reference for immediate fixes- Dead export removal (causes console errors)
- DOMContentLoaded cleanup (prevents wrong-page execution)
- Step-by-step instructions for common fixes
- Use as reference during this migration
-
ALPINE_COMPLETION_GUIDE.md- This document- Full reactive Alpine.js migration path
- Eliminate all manual DOM manipulation
- Complete code examples and patterns
- Long-term architecture goal
What's Still Missing ❌
Problem: 121 instances of manual DOM manipulation in TypeScript files
Example from header.ts:7-17:
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-showdirectives - No
@click.outsidefor closing dropdowns - No
x-transitionfor animations
What Needs Migration
8 stateful templates (modals, dropdowns, wizards):
- ✅ header.templ - Theme dropdown + user menu (P0 - used in 17 places)
- ✅ collection_modal.templ - Create/edit collection modal
- ✅ collections.templ - Add books modal + navigation
- ✅ conflicts.templ - Conflict resolution modal
- ✅ queue.templ - Queue actions modal
- ✅ admin.templ - Scan progress modal
- ✅ devices.templ - Device token modal
- ✅ 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 5-step pattern:
- Prerequisites (Phase 0): Remove dead exports that cause console errors
- Template Changes: Add
x-datastate, replaceclass="hidden"withx-show, add transitions - TypeScript Cleanup: Remove manual DOM manipulation functions
- Alpine Registration: Remove deleted functions from
Alpine.global()orAlpine.data() - Testing: Verify functionality, build, check for regressions
Key Principles
SSR-First (see SSR_FIRST_ALPINE_GUIDE.md):
- ❌ NEVER fetch data in x-init if data is already SSR'd
- ✅ x-init ONLY for setup (event listeners, modals)
- ✅ Data fetch ONLY after user actions (create/delete/update)
- ✅ State lives in template (
x-data), not in TypeScript
Alpine.js Best Practices:
- 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 0: Prerequisites (Dead Export Removal)
Before starting full migration, fix immediate console errors caused by dead Alpine.js exports.
Why This Phase?
When functions are deleted from TypeScript but remain in Alpine.data() exports, the browser console shows errors like:
addbooksToAdd is not definedremovebooksToAdd is not definedtoggleBookSelection is not defined
These must be fixed before attempting full migration.
Quick Reference
For detailed step-by-step instructions, see COLLECTIONS_CLEANUP_GUIDE.md - Step 1 covers this process comprehensively.
The Process
For collections.ts (and similar files):
- Identify dead exports:
# Check what's exported
grep -A25 "Alpine.data" web/src/collections.ts
# Find actual function definitions
grep -n "^function\|^async function" web/src/collections.ts
- Update export statement:
// Remove dead functions from export
export {
// Keep only existing functions
backToCollections,
closeCollectionModal,
// ... etc ...
};
- Update Alpine.data registration:
Alpine.data("collections", () => ({
// Keep only existing functions
backToCollections,
closeCollectionModal,
// ... etc ...
}));
- Verify:
npm run build:ts
# Should succeed with 0 errors
Files That Need This Fix
Based on commit 93710a1 and current errors:
- ✅
web/src/collections.ts- Already documented in COLLECTIONS_CLEANUP_GUIDE.md - Check other files for similar issues as you encounter them
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):
<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:
<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:
- ✅ Wrapped both dropdowns in single
x-datacontainer (line 1) - ✅ Replaced
@click="toggleThemeDropdown()"with@click="themeDropdownOpen = !themeDropdownOpen"(line 4) - ✅ Replaced
id="theme-dropdown" class="hidden"withx-show="themeDropdownOpen"(line 13) - ✅ Added
@click.outside="themeDropdownOpen = false"(line 14) - ✅ Added
x-transitiondirectives for smooth animations (lines 15-20) - ✅ Added
style="display: none;"to prevent flash of unstyled content (line 22)
Update theme buttons (lines 56, 60, 64, 68, 72, 76, 80):
Replace:
<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:
<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:
- ✅ Namespace added:
header.changeThemeTo('tokyo-night') - ✅ Close dropdown after selection:
themeDropdownOpen = false
Update wood paneling buttons (lines 87, 96, 108, 120):
Replace:
<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:
<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:
<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:
<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:
<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:
<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"withx-show="xxxOpen" - ✅ Added
@click.outsideto both dropdowns - ✅ Added
x-transitionfor 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:
// 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:
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:
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):
// 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):
// 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:
import { Alpine } from "./alpine";
// ... existing functions ...
Alpine.global("woodPaneling", {
change: changeWoodPaneling,
});
Step 1.4: Verify header.templ Migration
Build verification:
npm run build:ts
templ generate
Expected: No errors, templates compile successfully
Manual testing:
- Start application:
go run . - Navigate to any page with header (all pages)
- 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
- 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
- Test wood paneling:
- Click wood paneling option → changes, dropdown closes
Debug with Alpine DevTools (optional):
# Install Alpine DevTools browser extension
# Open DevTools → Alpine tab
# Inspect reactive state: themeDropdownOpen, userMenuOpen
Common issues:
- ❌ Dropdown doesn't open: Check if
x-datais on parent container - ❌ Dropdown doesn't close: Check if
@click.outsideis on dropdown div - ❌ No smooth transition: Check if
x-transitiondirectives 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):
<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:
<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:
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:
<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:
<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:
// Create a global store for modal state
Alpine.store('modals', {
addBooks: false,
showAddBooks() {
this.addBooks = true;
},
hideAddBooks() {
this.addBooks = false;
}
});
In template, use:
<!-- 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:
function showAddBooksModal() { /* ... */ }
function hideAddBooksModal() { /* ... */ }
Keep only business logic:
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:
Alpine.store('modals', {
resolveConflict: false,
showResolveConflict() {
this.resolveConflict = true;
},
hideResolveConflict() {
this.resolveConflict = false;
}
});
Template changes:
Current (line 123):
<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:
<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:
<button @click="showConflictModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
With:
<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: Other Templates (DOMContentLoaded Cleanup)
Before migrating templates to full reactive Alpine.js, clean up DOMContentLoaded listeners.
See SSR_FIRST_ALPINE_GUIDE.md for complete SSR-first architecture principles.
Quick Reference
For detailed instructions on dashboard, docs, and other pages, see COLLECTIONS_CLEANUP_GUIDE.md - Step 3 covers DOMContentLoaded removal.
The Pattern
Current (WRONG):
// ❌ Runs on EVERY page (main.ts imports all modules)
document.addEventListener("DOMContentLoaded", initializePage);
Solution 1: x-init Wrapper (Current Approach):
// ✅ Wrap in named function, call via x-init
function initializePage() {
setupEventListeners();
}
export { initializePage };
Alpine.data("page", () => ({
initializePage,
}));
<!-- Template -->
<body x-data="page" x-init="initializePage">
Solution 2: Event Delegation Only (Future Goal):
// ✅ Rely on global event delegation, no init needed
// See ALPINE_COMPLETION_GUIDE.md for full migration path
Files Requiring Cleanup
analytics.ts (Type 3 - 80% JavaScript page):
- ✅ Already correct - uses
x-init="loadAnalytics" - ✅ Data fetch is intentional for this dynamic page
docs.ts (Type 1 - 80% SSR page):
- ✅ Remove DOMContentLoaded
- ✅ Add
x-init="initializeDocsSearch"to template - ✅ Simple setup only, no data fetch
dashboard.ts (Type 2 - SSR + Interactive page):
- ✅ Wrap existing DOMContentLoaded code in
initDashboard()function - ✅ Add
x-data="dashboard" x-init="initDashboard"to template - ✅ Does NOT fetch data on page load (SSR provides initial dashboard)
- ✅ Event delegation already in place with
data-actionattributes
library.ts (Type 2 - SSR + Interactive page):
- ✅ Already fixed (commit
1b9bc64) - ✅ Removed
reloadLibraries()frominitializeLibraryAdmin() - ✅ SSR provides initial library list
Implementation Steps
For each file:
- Remove DOMContentLoaded:
// DELETE:
// document.addEventListener("DOMContentLoaded", initializePage);
- Export the init function:
export { initializePage };
- Add to Alpine.data:
Alpine.data("page", () => ({
initializePage,
}));
- Update template:
<!-- BEFORE -->
<body class="theme-{ user.Theme }">
<!-- AFTER -->
<body x-data="page" x-init="initializePage" class="theme-{ user.Theme }">
- Verify SSR-first principles:
- ✅ x-init does NOT fetch data (if Type 1 or Type 2)
- ✅ x-init ONLY sets up event listeners
- ✅ Data fetch happens only after user actions
Phase 4: Verification & Testing
For Each Migrated Template
Build Verification
# 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:
# 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 5: Cleanup
4.1: Remove Unused Functions
After all templates are migrated, search for remaining manual DOM manipulation:
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:
## 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: How This Guide Relates to Others
Three complementary guides:
-
SSR_FIRST_ALPINE_GUIDE.md- READ THIS FIRST- SSR-first architecture principles
- Page type classifications (Type 1, 2, 3)
- When to fetch data (and when NOT to)
- Server-side token injection
- Must read before using this guide
-
COLLECTIONS_CLEANUP_GUIDE.md- Quick reference for immediate fixes- Dead export removal (Phase 0 prerequisites)
- DOMContentLoaded cleanup (Phase 3)
- Template regeneration
- Build verification steps
- Use as step-by-step reference
-
ALPINE_COMPLETION_GUIDE.md- This document- Full reactive Alpine.js migration
- Eliminate all manual DOM manipulation
- Header template reference implementation
- Modal templates migration
- Long-term architecture goal
4.4: Create Migration Documentation
Create docs/contributing/alpinejs-patterns.md:
# 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; }
});
<!-- 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:
<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:
- Check if
x-datais on parent container - Check if Alpine is loaded:
<script src="/static/main.js"></script> - Check for JavaScript errors in console
- Verify variable name matches:
x-data="{ open: false }"andx-show="open"
Issue: Dropdown doesn't close when clicking outside
Symptoms: Click outside, dropdown stays open
Solutions:
- Check if
@click.outsideis on dropdown div (not button) - Check for z-index conflicts
- Check if other elements are blocking clicks
Issue: No smooth transition
Symptoms: Dropdown appears instantly without animation
Solutions:
- Check if
x-transitionis present - Check if
display: none;is inline style (prevents FOUC) - Check for CSS conflicts (transition properties)
Issue: Flash of unstyled content (FOUC)
Symptoms: Dropdown briefly visible before Alpine loads
Solutions:
- Add inline style:
style="display: none;" - Alpine will override this when it loads
- Combine with
x-showfor reactive behavior
Issue: Modal closes when clicking inside
Symptoms: Click inside modal, it closes
Solutions:
- Use
@click.selfinstead of@click.outside - Add
@click.stopon inner elements to stop propagation - Check modal structure - ensure backdrop has click handler, not content
Issue: TypeScript compilation error
Symptoms: npm run build:ts fails after changes
Solutions:
- Check for undefined functions (removed but still referenced)
- Check Alpine.global() registration (remove deleted functions)
- Run
npm run build:ts -- --verbosefor 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
# 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)
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)
// 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):
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):
<!-- 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
Recommended Order
-
Read
SSR_FIRST_ALPINE_GUIDE.mdfirst- Understand SSR-first architecture
- Learn page type classifications
- Know when to fetch data
-
Fix immediate console errors (if needed)
- See
COLLECTIONS_CLEANUP_GUIDE.mdStep 1 - Remove dead exports
- Clean up DOMContentLoaded listeners
- Verify builds work
- See
-
Start with header.templ migration (this guide, Phase 1)
- Highest priority (used in 17 templates)
- Reference implementation for all other templates
- Learn the pattern
-
Apply Alpine.store pattern to modals (this guide, Phase 2)
- Collections, conflicts, queue, devices, profile
- Consistent modal state management
- Remove show/hide functions from TypeScript
-
Complete remaining templates (page-by-page)
- Use header.templ as reference
- Test thoroughly after each migration
- Commit frequently with detailed messages
-
Clean up and verify (this guide, Phase 5)
- Remove unused functions
- Check for remaining manual DOM manipulation
- Update documentation
Documentation Strategy
Goal: Eventually deprecate COLLECTIONS_CLEANUP_GUIDE.md once all patterns are understood.
Current state:
SSR_FIRST_ALPINE_GUIDE.md- Architecture principles (permanent reference)ALPINE_COMPLETION_GUIDE.md- Full migration guide (active use)COLLECTIONS_CLEANUP_GUIDE.md- Step-by-step fixes (quick reference, will be deprecate)
Key Success Factors
- Follow SSR-first principles - Don't break SSR with data fetches in x-init
- Test thoroughly - Each template migration should be verified
- Commit frequently - Small, focused commits with detailed messages
- Learn the pattern - Header template is the reference for all others
- Be patient - This is a 10-12 hour migration across many templates