Merge COLLECTIONS_CLEANUP_GUIDE.md into ALPINE_COMPLETION_GUIDE.md to create a single, comprehensive migration guide. This consolidates documentation and reduces redundancy. Changes: - Update guide structure from three guides to two guides - Remove references to COLLECTIONS_CLEANUP_GUIDE.md - Add Phase 0 (dead export removal) with detailed step-by-step instructions - Add Phase 3 (DOMContentLoaded cleanup) with file-by-file instructions - Incorporate detailed fixes for collections.ts, analytics.ts, docs.ts, dashboard.ts, and library.ts - Update all cross-references to point to consolidated guide - Add implementation steps and verification commands Documentation consolidation rationale: - Single source of truth for Alpine.js migration - Eliminates need to reference multiple documents - Maintains all step-by-step instructions in one place - Simplifies maintenance and updates Deleted: COLLECTIONS_CLEANUP_GUIDE.md (content merged into ALPINE_COMPLETION_GUIDE.md)
64 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)
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
Two 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
-
ALPINE_COMPLETION_GUIDE.md- This document- Full reactive Alpine.js migration path
- Dead export removal (Phase 0)
- DOMContentLoaded cleanup (Phase 3)
- Template function call fixes
- 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.
Step 0.1: Verify Current State
Before starting, check the current errors:
cd /home/nymusicman/Code/bookhoard
npm run build:ts
# Should see errors about:
# - "addbooksToAdd is not defined"
# - "removebooksToAdd is not defined"
# - "toggleBookSelection is not defined"
# - etc.
Step 0.2: Fix collections.ts Alpine.data Export
File: web/src/collections.ts
Problem: Alpine.data exports functions that were deleted in commit 93710a1.
Step 0.2.1: Read Current Alpine.data Export
Check what's currently exported:
# Check what's currently exported
tail -50 web/src/collections.ts | grep -A25 "Alpine.data"
You'll see something like:
Alpine.data("collections", () => ({
addbooksToAdd, // ❌ Does NOT exist - deleted
backToCollections,
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
hideAddBooksModal,
initCollectionDetail, // ❌ Does NOT exist - deleted
initColorSelection, // ❌ Does NOT exist - deleted
initIconSelection, // ❌ Does NOT exist - deleted
loadCollectionRules,
loadCollections,
navigateToCollection,
populateIconGrid,
removeBook,
removebooksToAdd, // ❌ Does NOT exist - deleted
searchBooksForCollections,
selectColor,
selectIcon,
showAddBooksModal,
showAllIcons, // ❌ Does NOT exist - deleted
setupHTMXAuth,
testRule,
toggleBookForRemoval, // ❌ Does NOT exist - deleted
toggleBookSelection, // ❌ Does NOT exist - deleted
updateSelectedCount,
}));
Step 0.2.2: Read Current Export Statement
Check the export statement at the end of the file:
# Check the export statement at the end of the file
grep -A30 "^export {" web/src/collections.ts
You'll see similar dead exports.
Step 0.2.3: Verify Which Functions Actually Exist
Search for function definitions:
# Search for function definitions
grep -n "^function\|^async function" web/src/collections.ts
Expected output (actual existing functions):
backToCollections✓closeCollectionModal✓createRule✓deleteRule✓filterCollectionBooks✓filterIcons✓hideAddBooksModal✓loadCollectionRules✓loadCollections✓navigateToCollection✓populateIconGrid✓removeBook✓searchBooksForCollections✓selectColor✓selectIcon✓showAddBooksModal✓setupHTMXAuth✓testRule✓updateSelectedCount✓
Step 0.2.4: Update the Export Statement
Line ~407: Change the export statement to only include existing functions:
export {
backToCollections,
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
hideAddBooksModal,
loadCollectionRules,
loadCollections,
navigateToCollection,
populateIconGrid,
removeBook,
searchBooksForCollections,
selectColor,
selectIcon,
showAddBooksModal,
setupHTMXAuth,
testRule,
updateSelectedCount,
};
Step 0.2.5: Update Alpine.data Registration
Line ~424: Update Alpine.data to match the export:
Alpine.data("collections", () => ({
backToCollections,
closeCollectionModal,
createRule,
deleteRule,
filterCollectionBooks,
filterIcons,
hideAddBooksModal,
loadCollectionRules,
loadCollections,
navigateToCollection,
populateIconGrid,
removeBook,
searchBooksForCollections,
selectColor,
selectIcon,
showAddBooksModal,
setupHTMXAuth,
testRule,
updateSelectedCount,
}));
Step 0.2.6: Verify the Fix
# Build TypeScript
npm run build:ts
# Should now succeed with 0 errors
Step 0.3: Fix Template Function Calls
File: templates/collections.templ
Problem: Template calls functions that no longer exist.
Step 0.3.1: Remove Dead Function Calls from Collection Detail Page
Line ~226: Remove the removebooksToAdd call:
<!-- BEFORE -->
<button
id="bulk-remove-btn"
@click="removebooksToAdd"
disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
🗑️ Remove Selected
</button>
<!-- AFTER -->
<button
id="bulk-remove-btn"
disabled
class="btn-danger px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
🗑️ Remove Selected
</button>
Line ~229: Remove addbooksToAdd call:
<!-- BEFORE -->
<button @click="addbooksToAdd" class="btn-primary px-4 py-2 rounded-lg">
➕ Add Selected Books
</button>
<!-- AFTER -->
<button type="button" class="btn-primary px-4 py-2 rounded-lg" style="opacity: 0.5; cursor: not-allowed;" disabled>
➕ Add Selected Books
</button>
Step 0.3.2: Regenerate Templates
# Generate Go template files
templ generate
# Should see: Complete [updates=0 duration=~40ms]
Step 0.4: Check Other Files for Similar Issues
Based on commit 93710a1 and current errors:
- ✅
web/src/collections.ts- Fixed above - Check other files for similar issues as you encounter them
General process for any file:
- Identify dead exports:
# Check what's exported
grep -A25 "Alpine.data" web/src/FILENAME.ts
# Find actual function definitions
grep -n "^function\|^async function" web/src/FILENAME.ts
-
Update export statement to remove dead functions
-
Update Alpine.data registration to remove dead functions
-
Update templates to remove dead function calls
-
Verify:
npm run build:tsandtempl generate
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.
The Problem
DOMContentLoaded listeners run on EVERY page due to main.ts importing all modules.
Example:
// web/src/admin.ts
document.addEventListener("DOMContentLoaded", () => {
initializeAdmin(); // Runs on index page!
});
// web/src/main.ts
import "./admin"; // Imports admin module on ALL pages
import "./dashboard"; // Imports dashboard module on ALL pages
The Solution: Two Approaches
Approach 1: x-init Wrapper (Current Approach)
Wrap DOMContentLoaded logic in named function, call via x-init:
// web/src/admin.ts
function initializeAdmin() {
setupEventListeners();
}
Alpine.data("admin", () => ({
initializeAdmin,
}));
<!-- templates/admin.templ -->
<body x-data="admin" x-init="initializeAdmin">
Approach 2: Event Delegation Only (Future)
Remove x-init entirely, rely on global event delegation:
// web/src/admin.ts
// NO init function - use global event delegation
// Global event listener checks for data-action attributes
document.addEventListener("click", (e) => {
const action = e.target.closest("[data-action]")?.dataset.action;
if (action === "delete-library") deleteLibrary();
});
<!-- templates/admin.templ -->
<body>
<!-- No x-init needed -->
<button data-action="delete-library">Delete</button>
</body>
Which to Use?
- Current state: Use Approach 1 (x-init wrapper)
- Future goal: Use Approach 2 (event delegation only)
Files Requiring Cleanup
3.1: analytics.ts - ALREADY CORRECT ✅
File: web/src/analytics.ts
Type: Type 3 (80% JavaScript page)
Status: Already correct - no changes needed
Current:
export { loadAnalytics };
document.addEventListener("DOMContentLoaded", loadAnalytics);
Alpine.data("analytics", () => ({
loadAnalytics,
}));
Change to:
export { loadAnalytics };
// REMOVE this line:
// document.addEventListener("DOMContentLoaded", loadAnalytics);
Alpine.data("analytics", () => ({
loadAnalytics,
}));
Template Update: templates/analytics.templ
Line ~14: Add x-init to body tag:
<!-- BEFORE -->
<body x-data="analytics" class="theme-{ user.Theme }">
<!-- AFTER -->
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
✅ Simple setup only ✅ Data fetch is intentional (analytics is a dynamic dashboard) ✅ x-init is appropriate here
3.2: docs.ts - SIMPLE SETUP ONLY
File: web/src/docs.ts
Type: Type 1 (80% SSR page)
Good news: initializeDocsSearch() ONLY sets up an event listener - no data fetch!
Current (around line 95-100):
document.addEventListener("DOMContentLoaded", () => {
initializeDocsSearch();
});
Solution: Remove DOMContentLoaded, use x-init
Remove DOMContentLoaded:
// DELETE:
// document.addEventListener("DOMContentLoaded", () => {
// initializeDocsSearch();
// });
export { toggleSidebar, initializeDocsSearch };
Alpine.data("docs", () => ({
toggleSidebar,
initializeDocsSearch, // Keep as-is
}));
Update template:
<!-- templates/docs.templ -->
<body x-data="docs" x-init="initializeDocsSearch" class="theme-{ user.Theme }">
✅ Simple setup only ✅ No data fetch (search is client-side) ✅ x-init is appropriate here
3.3: library.ts - ALREADY FIXED ✅
File: web/src/library.ts
Type: Type 2 (SSR + Interactive page)
Status: ✅ COMPLETED - See commit 1b9bc64
The SSR bug has been fixed:
- Removed
void reloadLibraries()frominitializeLibraryAdmin() - SSR provides initial library list (no fetch on page load)
reloadLibraries()available for after CRUD operations only- See
SSR_FIRST_ALPINE_GUIDE.mdfor complete SSR-first principles
Current state (web/src/library.ts:653-655):
function initializeLibraryAdmin(): void {
// Setup event listeners
const librariesList = document.getElementById("libraries-list");
if (librariesList) {
librariesList.addEventListener("click", handleLibraryListClick);
}
// ... setup code ...
// ✅ FIXED: No data fetch - SSR provides initial library list
// reloadLibraries() is called AFTER create/delete/update operations only
}
Template (templates/admin_library.templ:11):
<body x-data="library" x-init="initializeLibraryAdmin" class="theme-{ user.Theme }">
✅ No changes needed - SSR bug is already fixed.
3.4: dashboard.ts - WRAP EXISTING CODE
File: web/src/dashboard.ts
Type: Type 2 (SSR + Interactive page)
Good news: Dashboard already uses event delegation with data-action attributes!
Current (lines 494-521):
document.addEventListener("DOMContentLoaded", () => {
initDragAndDrop();
document.addEventListener("click", (e: Event) => {
// ... event delegation with data-action ...
});
// ... library select setup ...
});
Solution: Wrap existing DOMContentLoaded code in initDashboard() function
Create wrapper function at end of dashboard.ts:
function initDashboard() {
initDragAndDrop(); // Setup drag-drop
document.addEventListener("click", (e: Event) => {
const target = e.target as HTMLElement;
const actionElem = target.closest("[data-action]") as HTMLElement;
const action = actionElem?.getAttribute("data-action");
switch (action) {
case "scroll-carousel": {
const collectionId =
target.dataset.direction || actionElem?.dataset.direction || "0";
if (collectionId) scrollCarousel(collectionId, direction);
break;
}
// ... keep all existing cases ...
}
});
document.addEventListener("input", (e: Event) => {
const target = e.target as HTMLElement;
const actionElem = target.closest("[data-input-action]") as HTMLElement;
const action = actionElem?.getAttribute("data-input-action");
switch (action) {
case "update-items-count": {
const input = target as HTMLInputElement;
const displayTarget = input.getAttribute("target");
if (displayTarget) updateItemsCount(input, displayTarget);
break;
}
}
});
// Load saved library on page load
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
if (librarySelect) {
librarySelect.addEventListener("change", (e) => {
const target = e.target as HTMLSelectElement;
if (target.value) {
switchLibrary(target.value);
}
});
}
const savedLibrary = localStorage.getItem("selectedLibrary");
const currentLibrary = new URLSearchParams(window.location.search).get(
"library_id",
);
if (savedLibrary && savedLibrary !== currentLibrary) {
window.location.href = `/dashboard?library_id=${savedLibrary}`;
}
}
export { initDashboard };
Alpine.data("dashboard", () => ({
initDashboard,
}));
Remove the old DOMContentLoaded block (delete lines 494-521):
// DELETE this entire block:
// document.addEventListener("DOMContentLoaded", () => {
// initDragAndDrop();
// ... all 70+ lines ...
// });
Add to export statement:
export {
closeDashboardSettings,
openDashboardSettings,
initDashboard, // ← ADD THIS
saveDashboardSettings,
scrollCarousel,
// ... keep all other exports ...
};
Add to Alpine.data:
Alpine.data("dashboard", () => ({
closeDashboardSettings,
openDashboardSettings,
initDashboard, // ← ADD THIS
saveDashboardSettings,
scrollCarousel,
// ... keep all other exports ...
}));
Template Update: templates/dashboard.templ
Line ~19: Add x-data and x-init to body tag:
<!-- BEFORE -->
<body class="theme-{ user.Theme }">
<!-- AFTER -->
<body class="theme-{ user.Theme }" x-data="dashboard" x-init="initDashboard()">
✅ Keeps event delegation pattern ✅ Wraps existing code (minimal changes) ✅ x-init does NOT fetch data (SSR provides initial dashboard) ✅ localStorage redirect is user preference, not data fetch
Implementation Steps (For Each File)
For each file that needs DOMContentLoaded cleanup:
- 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
Summary of Changes
Files Updated:
- ✅
web/src/analytics.ts- Remove DOMContentLoaded, add x-init - ✅
web/src/docs.ts- Remove DOMContentLoaded, add x-init - ✅
web/src/dashboard.ts- Wrap existing code in initDashboard() - ✅
web/src/library.ts- Already fixed (commit1b9bc64)
Templates Updated:
- ✅
templates/analytics.templ- Add x-init="loadAnalytics" - ✅
templates/docs.templ- Add x-init="initializeDocsSearch" - ✅
templates/dashboard.templ- Add x-data and x-init - ✅
templates/admin_library.templ- Already correct
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: Documentation Guide
Two 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
-
ALPINE_COMPLETION_GUIDE.md- This document- Full reactive Alpine.js migration
- Dead export removal (Phase 0)
- DOMContentLoaded cleanup (Phase 3)
- Template function call fixes
- 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 this guide, Phase 0: Dead Export Removal
- Remove dead exports
- Clean up DOMContentLoaded listeners
- Verify builds work
-
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
Current state:
SSR_FIRST_ALPINE_GUIDE.md- Architecture principles (permanent reference)ALPINE_COMPLETION_GUIDE.md- Complete migration guide with all steps (this document)
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