- Fix incorrect function references in ALPINE_COMPLETION_GUIDE.md - header.changeThemeTo -> changeTheme - header.logout -> logout - woodPaneling.change -> changeWoodPaneling - Add SSR-first principles section to PROJECT_GUIDELINES.md - Add page type classifications (Type 1, 2, 3) - Fix extra asterisks on line 43 - Update to reference TypeScript instead of JavaScript
2375 lines
69 KiB
Markdown
2375 lines
69 KiB
Markdown
# 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
|
||
|
||
1. [Current State Analysis](#current-state-analysis)
|
||
2. [Migration Strategy](#migration-strategy)
|
||
3. [Phase 0: Prerequisites (Dead Export Removal)](#phase-0-prerequisites-dead-export-removal)
|
||
4. [Phase 1: Header Template (Reference Implementation)](#phase-1-header-template-reference-implementation)
|
||
5. [Phase 2: Modal Templates]((#phase-2-modal-templates)
|
||
6. [Phase 3: Other Templates (DOMContentLoaded Cleanup)](#phase-3-other-templates-domcontentloaded-cleanup)
|
||
7. [Phase 4: Verification & Testing]((#phase-4-verification--testing)
|
||
8. [Phase 5: Cleanup]((#phase-5-cleanup)
|
||
9. [Troubleshooting](#troubleshooting)
|
||
10. [Success Criteria](#success-criteria)
|
||
|
||
---
|
||
|
||
## Current State Analysis
|
||
|
||
## Current State Analysis
|
||
|
||
### What's Already Done ✅
|
||
|
||
- All `onclick` handlers converted to `@click` directives
|
||
- Functions registered with `Alpine.global()` in TypeScript
|
||
- 18 templates have `x-data="namespace"` attributes
|
||
- HTMX integration working for forms
|
||
- SSR-first architecture documented in `SSR_FIRST_ALPINE_GUIDE.md`
|
||
|
||
### Documentation Structure
|
||
|
||
**Two complementary guides:**
|
||
|
||
1. **`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**
|
||
|
||
2. **`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:**
|
||
|
||
```typescript
|
||
const toggleThemeDropdown = (): void => {
|
||
const dropdown = document.getElementById("theme-dropdown");
|
||
if (dropdown) {
|
||
dropdown.classList.toggle("hidden"); // ← Manual DOM manipulation!
|
||
const userMenu = document.getElementById("user-menu");
|
||
if (userMenu && !dropdown.classList.contains("hidden")) {
|
||
userMenu.classList.add("hidden"); // ← Manual DOM manipulation!
|
||
}
|
||
}
|
||
};
|
||
```
|
||
|
||
**Templates still using:**
|
||
|
||
- `id="theme-dropdown"` + `class="hidden"` for show/hide
|
||
- No reactive state variables
|
||
- No `x-show` directives
|
||
- No `@click.outside` for closing dropdowns
|
||
- No `x-transition` for animations
|
||
|
||
### What Needs Migration
|
||
|
||
**8 stateful templates** (modals, dropdowns, wizards):
|
||
|
||
1. ✅ **header.templ** - Theme dropdown + user menu (P0 - used in 17 places)
|
||
2. 🔄 **collection_modal.templ** - Create/edit collection modal (needs work - see 2.1)
|
||
3. ✅ **collections.templ** - Add books modal + navigation
|
||
4. ✅ **conflicts.templ** - Conflict resolution modal
|
||
5. ✅ **queue.templ** - Queue actions modal
|
||
6. ✅ **admin.templ** - Scan progress modal
|
||
7. ✅ **devices.templ** - Device token modal
|
||
8. ✅ **profile_modal.templ** - Profile edit modal
|
||
|
||
**Note**: Simple buttons with `@click` handlers are fine - no migration needed.
|
||
|
||
---
|
||
|
||
## Migration Strategy
|
||
|
||
### The Pattern
|
||
|
||
Every migration follows the same 5-step pattern:
|
||
|
||
1. **Prerequisites** (Phase 0): Remove dead exports that cause console errors
|
||
2. **Template Changes**: Add `x-data` state, replace `class="hidden"` with `x-show`, add transitions
|
||
3. **TypeScript Cleanup**: Remove manual DOM manipulation functions
|
||
4. **Alpine Registration**: Remove deleted functions from `Alpine.global()` or `Alpine.data()`
|
||
5. **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 defined`
|
||
- `removebooksToAdd is not defined`
|
||
- `showAllIcons is not defined`
|
||
|
||
**Important**: For the Add Books functionality, these functions should be RESTORED with the proper Alpine/HTMX pattern (see Section 2.2), not just removed. The backend API still exists and should work.
|
||
|
||
For other dead exports, they can be removed from Alpine.data if truly not needed.
|
||
|
||
---
|
||
|
||
### Step 0.1: Verify Current State
|
||
|
||
Before starting, check the current errors:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
# Check what's currently exported
|
||
tail -50 web/src/collections.ts | grep -A25 "Alpine.data"
|
||
```
|
||
|
||
You'll see something like:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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
|
||
|
||
```bash
|
||
# 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: Restore Add Books Functionality (Not Remove!)
|
||
|
||
**IMPORTANT**: These functions were accidentally deleted in commit 93710a1. The buttons were disabled but the backend API still exists. Instead of removing these, we need to RESTORE them with the proper Alpine/HTMX pattern.
|
||
|
||
**See Section 2.2 for complete instructions on restoring Add Books functionality.**
|
||
|
||
The new pattern uses:
|
||
1. Alpine.store for modal visibility
|
||
2. HTMX for book search (server-side)
|
||
3. HTMX form submission for adding books
|
||
4. On success: close modal + refresh books list
|
||
|
||
**Do NOT disable these buttons - restore the functionality!**
|
||
|
||
#### Step 0.3.2: Regenerate Templates
|
||
|
||
```bash
|
||
# 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:**
|
||
|
||
1. **Identify dead exports:**
|
||
|
||
```bash
|
||
# 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
|
||
```
|
||
|
||
1. **Update export statement** to remove dead functions
|
||
|
||
2. **Update Alpine.data registration** to remove dead functions
|
||
|
||
3. **Update templates** to remove dead function calls
|
||
|
||
4. **Verify:** `npm run build:ts` and `templ 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):**
|
||
|
||
```templ
|
||
<div class="relative">
|
||
<button @click="toggleThemeDropdown()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||
</svg>
|
||
</button>
|
||
<div id="theme-dropdown" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
|
||
```
|
||
|
||
**New Structure:**
|
||
|
||
```templ
|
||
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }">
|
||
<!-- Theme Switcher -->
|
||
<div class="relative">
|
||
<button @click="themeDropdownOpen = !themeDropdownOpen"
|
||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||
</svg>
|
||
</button>
|
||
<div x-show="themeDropdownOpen"
|
||
@click.outside="themeDropdownOpen = false"
|
||
x-transition:enter="transition ease-out duration-200"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-150"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
|
||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
|
||
```
|
||
|
||
**Key Changes:**
|
||
|
||
1. ✅ Wrapped both dropdowns in single `x-data` container (line 1)
|
||
2. ✅ Replaced `@click="toggleThemeDropdown()"` with `@click="themeDropdownOpen = !themeDropdownOpen"` (line 4)
|
||
3. ✅ Replaced `id="theme-dropdown" class="hidden"` with `x-show="themeDropdownOpen"` (line 13)
|
||
4. ✅ Added `@click.outside="themeDropdownOpen = false"` (line 14)
|
||
5. ✅ Added `x-transition` directives for smooth animations (lines 15-20)
|
||
6. ✅ Added `style="display: none;"` to prevent flash of unstyled content (line 22)
|
||
|
||
**Update theme buttons (lines 56, 60, 64, 68, 72, 76, 80):**
|
||
|
||
Replace:
|
||
|
||
```templ
|
||
<button @click="changeThemeTo('tokyo-night')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
```
|
||
|
||
With:
|
||
|
||
```templ
|
||
<button @click="changeTheme('tokyo-night'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
```
|
||
|
||
**Key Changes:**
|
||
|
||
1. ✅ Function called directly: `changeTheme('tokyo-night')` (from theme.ts)
|
||
2. ✅ Close dropdown after selection: `themeDropdownOpen = false`
|
||
|
||
**Update wood paneling buttons (lines 87, 96, 108, 120):**
|
||
|
||
Replace:
|
||
|
||
```templ
|
||
<button @click="changeWoodPaneling('none')" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary);" data-wood="none">
|
||
```
|
||
|
||
With:
|
||
|
||
```templ
|
||
<button @click="changeWoodPaneling('none'); themeDropdownOpen = false"
|
||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary);"
|
||
data-wood="none">
|
||
```
|
||
|
||
**Note**: We'll need to add `woodPaneling` namespace in Alpine registration (Step 1.3).
|
||
|
||
**Update user menu section (lines 136-160):**
|
||
|
||
Replace:
|
||
|
||
```templ
|
||
<div class="relative">
|
||
<button @click="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||
</svg>
|
||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
|
||
</button>
|
||
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
|
||
```
|
||
|
||
With:
|
||
|
||
```templ
|
||
<div class="relative">
|
||
<button @click="userMenuOpen = !userMenuOpen"
|
||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||
</svg>
|
||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
|
||
</button>
|
||
<div x-show="userMenuOpen"
|
||
@click.outside="userMenuOpen = false"
|
||
x-transition
|
||
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
|
||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
|
||
```
|
||
|
||
**Update logout button (line 156):**
|
||
|
||
Replace:
|
||
|
||
```templ
|
||
<button @click="logout()" class="block w-full text-left px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary);">
|
||
Logout
|
||
</button>
|
||
```
|
||
|
||
With:
|
||
|
||
```templ
|
||
<button @click="logout(); userMenuOpen = false"
|
||
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
|
||
style="color: var(--text-primary); background-color: var(--bg-secondary);">
|
||
Logout
|
||
</button>
|
||
```
|
||
|
||
**Complete header.templ changes summary:**
|
||
|
||
- ✅ Wrapped nav in `x-data="{ themeDropdownOpen: false, userMenuOpen: false }"`
|
||
- ✅ Replaced all `@click="toggleXxx()"` with state toggles
|
||
- ✅ Replaced `id="xxx"` + `class="hidden"` with `x-show="xxxOpen"`
|
||
- ✅ Added `@click.outside` to both dropdowns
|
||
- ✅ Added `x-transition` for smooth animations
|
||
- ✅ Use `changeTheme()`, `changeWoodPaneling()`, `logout()` directly (from x-data="theme" on nav)
|
||
- ✅ Close dropdowns after action: `themeDropdownOpen = false`
|
||
|
||
### Step 1.2: Update header.ts
|
||
|
||
**Location**: `web/src/header.ts`
|
||
**Lines to delete**: 7-31, 64-88
|
||
|
||
**Delete manual DOM manipulation functions:**
|
||
|
||
```typescript
|
||
// DELETE lines 7-31:
|
||
const toggleThemeDropdown = (): void => {
|
||
const dropdown = document.getElementById("theme-dropdown");
|
||
if (dropdown) {
|
||
dropdown.classList.toggle("hidden");
|
||
const userMenu = document.getElementById("user-menu");
|
||
if (userMenu && !dropdown.classList.contains("hidden")) {
|
||
userMenu.classList.add("hidden");
|
||
}
|
||
}
|
||
};
|
||
|
||
const toggleUserMenu = (): void => {
|
||
const menu = document.getElementById("user-menu");
|
||
if (menu) {
|
||
menu.classList.toggle("hidden");
|
||
const themeDropdown = document.getElementById("theme-dropdown");
|
||
if (themeDropdown && !menu.classList.contains("hidden")) {
|
||
themeDropdown.classList.add("hidden");
|
||
}
|
||
}
|
||
};
|
||
```
|
||
|
||
**Simplify changeThemeTo function (lines 33-55):**
|
||
|
||
**Before:**
|
||
|
||
```typescript
|
||
const changeThemeTo = (theme: string): void => {
|
||
applyTheme(theme);
|
||
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
fetch("/api/auth/theme", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({ theme }),
|
||
}).catch((err) => console.log("Theme save failed", err));
|
||
}
|
||
|
||
// Close dropdown ← Manual DOM manipulation!
|
||
const dropdown = document.getElementById("theme-dropdown");
|
||
if (dropdown) {
|
||
dropdown.classList.add("hidden");
|
||
}
|
||
};
|
||
```
|
||
|
||
**After:**
|
||
|
||
```typescript
|
||
const changeThemeTo = (theme: string): void => {
|
||
// Apply theme
|
||
applyTheme(theme);
|
||
|
||
// Save to server if logged in
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
fetch("/api/auth/theme", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({ theme }),
|
||
}).catch((err) => console.log("Theme save failed", err));
|
||
}
|
||
|
||
// No dropdown manipulation - Alpine handles it via template state!
|
||
};
|
||
```
|
||
|
||
**Delete click-outside event listener (lines 64-88):**
|
||
|
||
```typescript
|
||
// DELETE entire event listener - Alpine's @click.outside handles this:
|
||
document.addEventListener("click", (e) => {
|
||
const target = e.target as HTMLElement;
|
||
const themeDropdown = document.getElementById("theme-dropdown");
|
||
const userMenu = document.getElementById("user-menu");
|
||
const themeButton = target?.closest(
|
||
'button[onclick="toggleThemeDropdown()"]',
|
||
);
|
||
const userButton = target?.closest('button[onclick="toggleUserMenu()"]');
|
||
|
||
if (
|
||
!themeButton &&
|
||
themeDropdown &&
|
||
!themeDropdown.classList.contains("hidden")
|
||
) {
|
||
if (!themeDropdown.contains(target)) {
|
||
themeDropdown.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
if (!userButton && userMenu && !userMenu.classList.contains("hidden")) {
|
||
if (!userMenu.contains(target)) {
|
||
userMenu.classList.add("hidden");
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
**Final header.ts (after cleanup):**
|
||
|
||
```typescript
|
||
// Header functionality
|
||
|
||
import { Alpine } from "./alpine";
|
||
import { applyTheme } from "./theme";
|
||
import { updateThemeIndicators } from "./themeDropdown";
|
||
|
||
const changeThemeTo = (theme: string): void => {
|
||
// Apply the theme using the consolidated function from theme.ts
|
||
applyTheme(theme);
|
||
|
||
// Save to server if logged in
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
fetch("/api/auth/theme", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({ theme }),
|
||
}).catch((err) => console.log("Theme save failed", err));
|
||
}
|
||
|
||
// Alpine closes dropdown automatically via template state
|
||
};
|
||
|
||
const logout = (): void => {
|
||
localStorage.removeItem("token");
|
||
localStorage.removeItem("user");
|
||
window.location.href = "/";
|
||
};
|
||
|
||
export { changeThemeTo, logout };
|
||
|
||
Alpine.global("header", {
|
||
logout,
|
||
changeThemeTo: (theme: string) => {
|
||
changeThemeTo(theme);
|
||
updateThemeIndicators();
|
||
},
|
||
});
|
||
```
|
||
|
||
**Result**: header.ts reduced from **100 lines to 40 lines** (60% reduction)
|
||
|
||
### Step 1.3: Create woodPaneling.ts Namespace
|
||
|
||
**Location**: `web/src/woodPaneling.ts`
|
||
|
||
**Current**: Functions exist but need Alpine registration for namespace calls
|
||
|
||
**Add to bottom of woodPaneling.ts:**
|
||
|
||
```typescript
|
||
import { Alpine } from "./alpine";
|
||
|
||
// ... existing functions ...
|
||
|
||
Alpine.global("woodPaneling", {
|
||
change: changeWoodPaneling,
|
||
});
|
||
```
|
||
|
||
### Step 1.4: Verify header.templ Migration
|
||
|
||
**Build verification:**
|
||
|
||
```bash
|
||
npm run build:ts
|
||
templ generate
|
||
```
|
||
|
||
**Expected**: No errors, templates compile successfully
|
||
|
||
**Manual testing:**
|
||
|
||
1. Start application: `go run .`
|
||
2. Navigate to any page with header (all pages)
|
||
3. Test theme dropdown:
|
||
- [ ] Click theme button → dropdown opens with smooth transition
|
||
- [ ] Click outside → dropdown closes
|
||
- [ ] Click theme option → theme changes, dropdown closes
|
||
- [ ] User menu closes if open
|
||
4. Test user menu:
|
||
- [ ] Click user button → menu opens with smooth transition
|
||
- [ ] Click outside → menu closes
|
||
- [ ] Click logout → logout, menu closes
|
||
- [ ] Theme dropdown closes if open
|
||
5. Test wood paneling:
|
||
- [ ] Click wood paneling option → changes, dropdown closes
|
||
|
||
**Debug with Alpine DevTools (optional):**
|
||
|
||
```bash
|
||
# Install Alpine DevTools browser extension
|
||
# Open DevTools → Alpine tab
|
||
# Inspect reactive state: themeDropdownOpen, userMenuOpen
|
||
```
|
||
|
||
**Common issues:**
|
||
|
||
- ❌ Dropdown doesn't open: Check if `x-data` is on parent container
|
||
- ❌ Dropdown doesn't close: Check if `@click.outside` is on dropdown div
|
||
- ❌ No smooth transition: Check if `x-transition` directives are present
|
||
- ❌ Flash of unstyled content: Verify `style="display: none;"` is on x-show elements
|
||
|
||
---
|
||
|
||
## Phase 2: Modal Templates
|
||
|
||
All modal templates follow the same pattern. Apply consistently.
|
||
|
||
### 2.1: collection_modal.templ
|
||
|
||
**Location**: `templates/collection_modal.templ`
|
||
**Time**: 1 hour
|
||
**Complexity**: Medium (modal with color/icon pickers)
|
||
|
||
**Template Changes:**
|
||
|
||
**Current (line 4):**
|
||
|
||
```templ
|
||
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||
```
|
||
|
||
**No change needed** - `x-data="collections"` namespace is correct, but we need local state.
|
||
|
||
**Add local state to wrapper:**
|
||
|
||
<!-- Replace line 4: -->
|
||
<!-- ```templ -->
|
||
<!-- <div x-data="{ colorModalOpen: false }" x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"> -->
|
||
<!-- ``` -->
|
||
|
||
**Better approach - use Alpine.data():**
|
||
|
||
**In web/src/collections.ts, add:**
|
||
|
||
```typescript
|
||
Alpine.data("collections", () => ({
|
||
colorModalOpen: false,
|
||
iconModalOpen: false,
|
||
|
||
openColorModal() {
|
||
this.colorModalOpen = true;
|
||
},
|
||
|
||
closeColorModal() {
|
||
this.colorModalOpen = false;
|
||
},
|
||
|
||
// ... existing methods ...
|
||
}));
|
||
```
|
||
|
||
**Actually, for modals, the simplest approach:**
|
||
|
||
Since this is a modal loaded via HTMX, the modal visibility is handled by HTMX swap. However, there are still issues that need fixing:
|
||
|
||
1. **`closeCollectionModal()` uses DOM manipulation** (collections.ts:255-259):
|
||
```typescript
|
||
function closeCollectionModal(): void {
|
||
const modal = document.querySelector(".fixed.inset-0");
|
||
if (modal && modal.parentElement) {
|
||
modal.parentElement.remove(); // ← Manual DOM manipulation!
|
||
}
|
||
}
|
||
```
|
||
**Fix**: Replace the function to just remove the modal container:
|
||
```typescript
|
||
function closeCollectionModal(): void {
|
||
const modalContainer = document.getElementById("modal-container");
|
||
if (modalContainer) {
|
||
modalContainer.innerHTML = "";
|
||
}
|
||
}
|
||
```
|
||
Or better, use HTMX to swap in empty content after form submission.
|
||
|
||
2. **Vanilla JS event handlers** in the template (lines 52-53, 118-119):
|
||
```templ
|
||
oninput="filterIcons(this.value)"
|
||
onfocus="showAllIcons()"
|
||
```
|
||
**Fix**: Add wrapper methods to Alpine.data and use Alpine directives:
|
||
```typescript
|
||
// In web/src/collections.ts - add to Alpine.data
|
||
Alpine.data("collections", () => ({
|
||
// ... existing methods
|
||
filterIcons(value: string) {
|
||
filterIcons(value);
|
||
},
|
||
showAllIconsWrapper() {
|
||
showAllIcons();
|
||
},
|
||
initIconSelectionWrapper() {
|
||
initIconSelection();
|
||
},
|
||
}));
|
||
```
|
||
Then in template:
|
||
```templ
|
||
<input @input="filterIcons($el.value)" @focus="showAllIconsWrapper()" ...>
|
||
```
|
||
|
||
**Note**: The `showAllIcons()` and `initIconSelection()` functions were deleted in commit 93710a1 and need to be restored. They are vanilla JS that manipulates the icon grid DOM.
|
||
|
||
3. **`selectColor()`** function (lines 69-74, 135-140) uses class manipulation on color buttons - consider adding visual feedback with Alpine state
|
||
|
||
### 2.2: collections.templ - Add Books Modal
|
||
|
||
**Location**: `templates/collections.templ`
|
||
**Time**: 1-2 hours
|
||
**Complexity**: Medium (add books modal with search)
|
||
|
||
**IMPORTANT**: This modal was partially broken when functions were deleted in commit 93710a1. The buttons were disabled but the backend API still exists. This section restores full functionality.
|
||
|
||
**Step 1: Add Alpine.store for modal state**
|
||
|
||
In `web/src/collections.ts`, add at the top:
|
||
|
||
```typescript
|
||
// Alpine.store for modal state
|
||
Alpine.store("modals", {
|
||
addBooks: false,
|
||
showAddBooks() {
|
||
this.addBooks = true;
|
||
},
|
||
hideAddBooks() {
|
||
this.addBooks = false;
|
||
},
|
||
});
|
||
```
|
||
|
||
**Step 2: Add missing icon picker functions**
|
||
|
||
The following functions were deleted in commit 93710a1 but are still referenced in collection_modal.templ. You need to either:
|
||
|
||
**Option A**: Restore the vanilla JS functions (simpler):
|
||
```typescript
|
||
// Add back to web/src/collections.ts
|
||
function showAllIcons(): void {
|
||
const iconGrid = document.getElementById("icon-grid");
|
||
if (!iconGrid) return;
|
||
const buttons = iconGrid.querySelectorAll(".icon-btn");
|
||
buttons.forEach((btn) => {
|
||
(btn as HTMLElement).style.display = "";
|
||
});
|
||
}
|
||
|
||
function initIconSelection(): void {
|
||
populateIconGrid();
|
||
const iconInput = document.getElementById("collection-icon") as HTMLInputElement;
|
||
const searchInput = document.getElementById("icon-search") as HTMLInputElement;
|
||
if (iconInput && iconInput.value && searchInput) {
|
||
searchInput.value = iconInput.value;
|
||
selectIcon(iconInput.value);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Option B**: Create wrapper methods in Alpine.data:
|
||
|
||
In `web/src/collections.ts`, add to the Alpine.data registration:
|
||
|
||
```typescript
|
||
Alpine.data("collections", () => ({
|
||
// ... existing methods ...
|
||
|
||
// Icon picker helpers (wrap vanilla JS for Alpine)
|
||
showAllIconsWrapper() {
|
||
showAllIcons(); // calls the vanilla JS function
|
||
},
|
||
|
||
initIconSelectionWrapper() {
|
||
initIconSelection(); // calls the vanilla JS function
|
||
},
|
||
|
||
// Book search - HTMX integration
|
||
handleBookSearch(event) {
|
||
const searchTerm = event.target.value;
|
||
if (searchTerm.length >= 2) {
|
||
// Trigger HTMX to search - use hx-get on input instead
|
||
htmx.trigger(event.target, 'search');
|
||
}
|
||
},
|
||
|
||
// Add selected books - called after HTMX form submit
|
||
onBooksAdded() {
|
||
Alpine.store("modals").hideAddBooks();
|
||
// Trigger refresh of books list via HTMX
|
||
htmx.trigger(htmx.find('#books-container'), 'refresh');
|
||
},
|
||
}));
|
||
```
|
||
|
||
**Step 3: Update collection_modal.templ template**
|
||
|
||
Fix the vanilla JS event handlers:
|
||
|
||
```templ
|
||
<!-- Replace oninput/onfocus with Alpine directives -->
|
||
<input
|
||
type="text"
|
||
id="icon-search"
|
||
@input="filterIcons($el.value)"
|
||
@focus="showAllIconsWrapper()"
|
||
...
|
||
/>
|
||
```
|
||
|
||
**Step 4: Update collections.templ - Add Books Modal**
|
||
|
||
**Current broken state (line 233):**
|
||
```templ
|
||
<button type="button" class="btn-primary px-4 py-2 rounded-lg" style="opacity: 0.5; cursor: not-allowed;" disabled>
|
||
➕ Add Books
|
||
</button>
|
||
```
|
||
|
||
**Replace with working modal:**
|
||
|
||
```templ
|
||
<!-- Add to collections.templ body tag: add x-data="collections" if not present -->
|
||
|
||
<!-- Button to open modal (find and update line 233) -->
|
||
<button @click="$store.modals.showAddBooks()" class="btn-primary px-4 py-2 rounded-lg">
|
||
➕ Add Books
|
||
</button>
|
||
|
||
<!-- Add modal HTML before closing body tag (around line 303) -->
|
||
<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 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="$store.modals.hideAddBooks()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||
</div>
|
||
|
||
<!-- Search input with HTMX -->
|
||
<div class="mb-4">
|
||
<input
|
||
type="text"
|
||
id="book-search"
|
||
placeholder="Search books..."
|
||
class="w-full px-4 py-2 border rounded-lg"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
hx-get={ fmt.Sprintf("/api/collections/%s/books/search", collection.ID) }
|
||
hx-target="#book-results"
|
||
hx-trigger="input changed delay:300ms"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Search results container -->
|
||
<div id="book-results" class="max-h-64 overflow-y-auto mb-4"></div>
|
||
|
||
<!-- Add books form -->
|
||
<form
|
||
hx-post={ fmt.Sprintf("/api/collections/%s/books", collection.ID) }
|
||
hx-on::after-request="if(event.detail.successful) { $store.modals.hideAddBooks(); htmx.trigger(htmx.find('#books-container'), 'refresh'); }"
|
||
>
|
||
<div class="flex justify-end space-x-3">
|
||
<button type="button" @click="$store.modals.hideAddBooks()" class="btn-secondary px-4 py-2 rounded-lg">
|
||
Cancel
|
||
</button>
|
||
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
|
||
Add Selected Books
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**Key Patterns:**
|
||
1. **Modal visibility**: Alpine.store (`$store.modals.addBooks`)
|
||
2. **Book search**: HTMX `hx-get` on input with debounce - server returns HTML
|
||
3. **Add books**: HTMX form submission - on success, close modal + refresh books list
|
||
|
||
**Backend API endpoints (already exist):**
|
||
- `GET /api/collections/:id/books?search=query` - Search books
|
||
- `POST /api/collections/:id/books` - Add books to collection
|
||
class="p-2 hover:opacity-80 rounded"
|
||
style="color: var(--text-primary)">✕</button>
|
||
</div>
|
||
```
|
||
|
||
**Note**: The modal needs to be wrapped in x-data container that includes the button. Since the button and modal are far apart, we need to restructure.
|
||
|
||
**Better approach - use Alpine.store():**
|
||
|
||
**In web/src/collections.ts, add:**
|
||
|
||
```typescript
|
||
// Create a global store for modal state
|
||
Alpine.store("modals", {
|
||
addBooks: false,
|
||
showAddBooks() {
|
||
this.addBooks = true;
|
||
},
|
||
hideAddBooks() {
|
||
this.addBooks = false;
|
||
},
|
||
});
|
||
```
|
||
|
||
**In template, use:**
|
||
|
||
```templ
|
||
<!-- Button (line 156) -->
|
||
<button @click="$store.modals.showAddBooks()" class="btn-primary px-4 py-2 rounded-lg">
|
||
➕ Add Books
|
||
</button>
|
||
|
||
<!-- Modal (line 227) -->
|
||
<div x-show="$store.modals.addBooks"
|
||
x-transition
|
||
@click.self="$store.modals.hideAddBooks()"
|
||
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
|
||
```
|
||
|
||
### 2.3: conflicts.templ
|
||
|
||
**Location**: `templates/conflicts.templ`
|
||
**Time**: 1 hour
|
||
**Complexity**: Medium (resolution modal)
|
||
|
||
**Use Alpine.store pattern (same as collections):**
|
||
|
||
**In web/src/conflicts.ts, add:**
|
||
|
||
```typescript
|
||
Alpine.store("modals", {
|
||
resolveConflict: false,
|
||
showResolveConflict() {
|
||
this.resolveConflict = true;
|
||
},
|
||
hideResolveConflict() {
|
||
this.resolveConflict = false;
|
||
},
|
||
});
|
||
```
|
||
|
||
**Template changes:**
|
||
|
||
**Current (line 123):**
|
||
|
||
```templ
|
||
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
|
||
```
|
||
|
||
**Replace with:**
|
||
|
||
```templ
|
||
<div x-show="$store.modals.resolveConflict"
|
||
x-transition
|
||
@click.self="$store.modals.hideResolveConflict()"
|
||
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
||
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
|
||
```
|
||
|
||
**Update button (line 110):**
|
||
|
||
Replace:
|
||
|
||
```templ
|
||
<button @click="showResolveModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
|
||
```
|
||
|
||
With:
|
||
|
||
```templ
|
||
<button @click="conflicts.showResolve('{ conflict.ID }'); $store.modals.showResolveConflict()"
|
||
class="btn-primary px-4 py-2 rounded-lg">
|
||
```
|
||
|
||
**Remove from TypeScript:**
|
||
Delete `showConflictModal()`, `hideConflictModal()` functions.
|
||
|
||
**Note**: The actual function name is `showResolveModal` (not `showConflictModal`). Check the current function name in conflicts.ts and update accordingly.
|
||
|
||
### 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:**
|
||
|
||
```typescript
|
||
// web/src/admin.ts
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initializeAdmin(); // Runs on index page!
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// web/src/admin.ts
|
||
function initializeAdmin() {
|
||
setupEventListeners();
|
||
}
|
||
|
||
Alpine.data("admin", () => ({
|
||
initializeAdmin,
|
||
}));
|
||
```
|
||
|
||
```html
|
||
<!-- templates/admin.templ -->
|
||
<body x-data="admin" x-init="initializeAdmin"></body>
|
||
```
|
||
|
||
#### Approach 2: Event Delegation Only (Future)
|
||
|
||
Remove x-init entirely, rely on global event delegation:
|
||
|
||
```typescript
|
||
// 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();
|
||
});
|
||
```
|
||
|
||
```html
|
||
<!-- 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:**
|
||
|
||
```typescript
|
||
export { loadAnalytics };
|
||
|
||
document.addEventListener("DOMContentLoaded", loadAnalytics);
|
||
|
||
Alpine.data("analytics", () => ({
|
||
loadAnalytics,
|
||
}));
|
||
```
|
||
|
||
**Change to:**
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```html
|
||
<!-- BEFORE -->
|
||
<body x-data="analytics" class="theme-{ user.Theme }">
|
||
<!-- AFTER -->
|
||
<body
|
||
x-data="analytics"
|
||
x-init="loadAnalytics"
|
||
class="theme-{ user.Theme }"
|
||
></body>
|
||
</body>
|
||
```
|
||
|
||
**✅ 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):**
|
||
|
||
```typescript
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initializeDocsSearch();
|
||
});
|
||
```
|
||
|
||
**Solution:** Remove DOMContentLoaded, use x-init
|
||
|
||
**Remove DOMContentLoaded:**
|
||
|
||
```typescript
|
||
// DELETE:
|
||
// document.addEventListener("DOMContentLoaded", () => {
|
||
// initializeDocsSearch();
|
||
// });
|
||
|
||
export { toggleSidebar, initializeDocsSearch };
|
||
|
||
Alpine.data("docs", () => ({
|
||
toggleSidebar,
|
||
initializeDocsSearch, // Keep as-is
|
||
}));
|
||
```
|
||
|
||
**Update template:**
|
||
|
||
```html
|
||
<!-- templates/docs.templ -->
|
||
<body
|
||
x-data="docs"
|
||
x-init="initializeDocsSearch"
|
||
class="theme-{ user.Theme }"
|
||
></body>
|
||
```
|
||
|
||
**✅ 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()` from `initializeLibraryAdmin()`
|
||
- SSR provides initial library list (no fetch on page load)
|
||
- `reloadLibraries()` available for after CRUD operations only
|
||
- See `SSR_FIRST_ALPINE_GUIDE.md` for complete SSR-first principles
|
||
|
||
**Current state (web/src/library.ts:653-655):**
|
||
|
||
```typescript
|
||
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):**
|
||
|
||
```html
|
||
<body
|
||
x-data="library"
|
||
x-init="initializeLibraryAdmin"
|
||
class="theme-{ user.Theme }"
|
||
></body>
|
||
```
|
||
|
||
**✅ 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):**
|
||
|
||
```typescript
|
||
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:**
|
||
|
||
```typescript
|
||
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):
|
||
|
||
```typescript
|
||
// DELETE this entire block:
|
||
// document.addEventListener("DOMContentLoaded", () => {
|
||
// initDragAndDrop();
|
||
// ... all 70+ lines ...
|
||
// });
|
||
```
|
||
|
||
**Add to export statement:**
|
||
|
||
```typescript
|
||
export {
|
||
closeDashboardSettings,
|
||
openDashboardSettings,
|
||
initDashboard, // ← ADD THIS
|
||
saveDashboardSettings,
|
||
scrollCarousel,
|
||
// ... keep all other exports ...
|
||
};
|
||
```
|
||
|
||
**Add to Alpine.data:**
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```html
|
||
<!-- BEFORE -->
|
||
<body class="theme-{ user.Theme }">
|
||
<!-- AFTER -->
|
||
<body
|
||
class="theme-{ user.Theme }"
|
||
x-data="dashboard"
|
||
x-init="initDashboard()"
|
||
></body>
|
||
</body>
|
||
```
|
||
|
||
**✅ 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:
|
||
|
||
1. **Remove DOMContentLoaded:**
|
||
|
||
```typescript
|
||
// DELETE:
|
||
// document.addEventListener("DOMContentLoaded", initializePage);
|
||
```
|
||
|
||
1. **Export the init function:**
|
||
|
||
```typescript
|
||
export { initializePage };
|
||
```
|
||
|
||
1. **Add to Alpine.data:**
|
||
|
||
```typescript
|
||
Alpine.data("page", () => ({
|
||
initializePage,
|
||
}));
|
||
```
|
||
|
||
1. **Update template:**
|
||
|
||
```html
|
||
<!-- BEFORE -->
|
||
<body class="theme-{ user.Theme }">
|
||
<!-- AFTER -->
|
||
<body
|
||
x-data="page"
|
||
x-init="initializePage"
|
||
class="theme-{ user.Theme }"
|
||
></body>
|
||
</body>
|
||
```
|
||
|
||
1. **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 (commit 1b9bc64)
|
||
|
||
**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
|
||
|
||
```bash
|
||
# 1. Build TypeScript
|
||
npm run build:ts
|
||
|
||
# 2. Generate templates
|
||
templ generate
|
||
|
||
# 3. Check for errors
|
||
# Expected: "Complete" with 0 errors
|
||
```
|
||
|
||
#### Manual Testing Checklist
|
||
|
||
**For header.templ:**
|
||
|
||
- [ ] Theme dropdown opens with smooth transition
|
||
- [ ] Theme dropdown closes when clicking outside
|
||
- [ ] Theme changes when option clicked
|
||
- [ ] Theme dropdown closes after selection
|
||
- [ ] User menu opens with smooth transition
|
||
- [ ] User menu closes when clicking outside
|
||
- [ ] Logout works and closes menu
|
||
- [ ] Wood paneling changes work
|
||
- [ ] Both dropdowns don't open simultaneously (mutual exclusion)
|
||
|
||
**For each modal template:**
|
||
|
||
- [ ] Modal opens with smooth transition
|
||
- [ ] Modal closes when clicking outside
|
||
- [ ] Modal closes when clicking X button
|
||
- [ ] Modal closes when clicking Cancel button
|
||
- [ ] Form submission works (HTMX)
|
||
- [ ] No console errors
|
||
|
||
#### Regression Testing
|
||
|
||
After each template migration:
|
||
|
||
```bash
|
||
# 1. Start application
|
||
go run .
|
||
|
||
# 2. Test all pages
|
||
- [ ] Dashboard loads
|
||
- [ ] Library loads
|
||
- [ ] Collections loads
|
||
- [ ] Devices loads
|
||
- [ ] Admin loads (if admin)
|
||
|
||
# 3. Test HTMX functionality
|
||
- [ ] Forms submit correctly
|
||
- [ ] Toast notifications appear
|
||
- [ ] Page updates work
|
||
|
||
# 4. Check browser console
|
||
- [ ] No JavaScript errors
|
||
- [ ] No 404s for .js files
|
||
- [ ] Alpine is loaded (check: window.Alpine in console)
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5: Cleanup
|
||
|
||
### 4.1: Remove Unused Functions
|
||
|
||
After all templates are migrated, search for remaining manual DOM manipulation:
|
||
|
||
```bash
|
||
cd /home/nymusicman/Code/bookhoard/web/src
|
||
|
||
# Find remaining classList manipulation
|
||
grep -rn "classList.toggle\|classList.add\|classList.remove" *.ts
|
||
|
||
# Find remaining getElementById for show/hide
|
||
grep -rn "getElementById.*dropdown\|getElementById.*modal" *.ts
|
||
|
||
# Find remaining event listeners for click-outside
|
||
grep -rn "addEventListener.*click" *.ts
|
||
```
|
||
|
||
**Expected**: Only 0-5 instances remaining (legitimate use cases)
|
||
|
||
### 4.2: Update ESBUILD_MIGRATION_PLAN.md
|
||
|
||
Add completion note:
|
||
|
||
```markdown
|
||
## Migration Status
|
||
|
||
### ✅ Completed
|
||
|
||
- Phase 0: ES Module Exports
|
||
- Phase 1: Internal TypeScript Dependencies
|
||
- Phase 2: Dual Exports (Alpine Bridge)
|
||
- Phase 3: Template Migration (ALL templates)
|
||
|
||
### 🎯 Final State
|
||
|
||
- 0 manual DOM manipulations (down from 121)
|
||
- All stateful templates using x-data/x-show
|
||
- Smooth transitions with x-transition
|
||
- Click-outside handling with @click.outside
|
||
- 60% less TypeScript code (header.ts: 100 → 40 lines)
|
||
```
|
||
|
||
### 4.3: Documentation Guide
|
||
|
||
**Two complementary guides:**
|
||
|
||
1. **`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**
|
||
|
||
2. **`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`:
|
||
|
||
````markdown
|
||
# Alpine.js Patterns in Bookhoard
|
||
|
||
## Modal Pattern
|
||
|
||
For modals loaded via HTMX, use Alpine.store:
|
||
|
||
```typescript
|
||
// In TypeScript
|
||
Alpine.store("modals", {
|
||
myModal: false,
|
||
show() {
|
||
this.myModal = true;
|
||
},
|
||
hide() {
|
||
this.myModal = false;
|
||
},
|
||
});
|
||
```
|
||
````
|
||
|
||
```templ
|
||
<!-- In template -->
|
||
<button @click="$store.modals.show()">Open</button>
|
||
<div x-show="$store.modals.myModal"
|
||
@click.self="$store.modals.hide()"
|
||
x-transition>
|
||
<!-- Modal content -->
|
||
</div>
|
||
```
|
||
|
||
## Dropdown Pattern
|
||
|
||
For dropdowns in same component, use local x-data:
|
||
|
||
```templ
|
||
<div x-data="{ dropdownOpen: false }">
|
||
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
|
||
<div x-show="dropdownOpen"
|
||
@click.outside="dropdownOpen = false"
|
||
x-transition>
|
||
<!-- Dropdown content -->
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## Best Practices
|
||
|
||
- ✅ State lives in template (x-data)
|
||
- ✅ UI updates automatically (x-show)
|
||
- ✅ No manual DOM manipulation in TypeScript
|
||
- ✅ Pure business logic only in TypeScript functions
|
||
- ✅ Use Alpine.store for global state
|
||
- ✅ Use local x-data for component state
|
||
- ❌ Never use classList in TypeScript
|
||
- ❌ Never use getElementById for show/hide
|
||
|
||
````
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
### Issue: Dropdown doesn't open
|
||
|
||
**Symptoms**: Click button, nothing happens
|
||
|
||
**Diagnosis**:
|
||
```javascript
|
||
// Open browser console
|
||
console.log(window.Alpine); // Should be defined
|
||
|
||
// Check if x-data is present
|
||
// Inspect element - should have x-data attribute
|
||
````
|
||
|
||
**Solutions**:
|
||
|
||
1. Check if `x-data` is on parent container
|
||
2. Check if Alpine is loaded: `<script src="/static/main.js"></script>`
|
||
3. Check for JavaScript errors in console
|
||
4. Verify variable name matches: `x-data="{ open: false }"` and `x-show="open"`
|
||
|
||
### Issue: Dropdown doesn't close when clicking outside
|
||
|
||
**Symptoms**: Click outside, dropdown stays open
|
||
|
||
**Solutions**:
|
||
|
||
1. Check if `@click.outside` is on dropdown div (not button)
|
||
2. Check for z-index conflicts
|
||
3. Check if other elements are blocking clicks
|
||
|
||
### Issue: No smooth transition
|
||
|
||
**Symptoms**: Dropdown appears instantly without animation
|
||
|
||
**Solutions**:
|
||
|
||
1. Check if `x-transition` is present
|
||
2. Check if `display: none;` is inline style (prevents FOUC)
|
||
3. Check for CSS conflicts (transition properties)
|
||
|
||
### Issue: Flash of unstyled content (FOUC)
|
||
|
||
**Symptoms**: Dropdown briefly visible before Alpine loads
|
||
|
||
**Solutions**:
|
||
|
||
1. Add inline style: `style="display: none;"`
|
||
2. Alpine will override this when it loads
|
||
3. Combine with `x-show` for reactive behavior
|
||
|
||
### Issue: Modal closes when clicking inside
|
||
|
||
**Symptoms**: Click inside modal, it closes
|
||
|
||
**Solutions**:
|
||
|
||
1. Use `@click.self` instead of `@click.outside`
|
||
2. Add `@click.stop` on inner elements to stop propagation
|
||
3. Check modal structure - ensure backdrop has click handler, not content
|
||
|
||
### Issue: TypeScript compilation error
|
||
|
||
**Symptoms**: `npm run build:ts` fails after changes
|
||
|
||
**Solutions**:
|
||
|
||
1. Check for undefined functions (removed but still referenced)
|
||
2. Check Alpine.global() registration (remove deleted functions)
|
||
3. Run `npm run build:ts -- --verbose` for detailed error
|
||
|
||
---
|
||
|
||
## Success Criteria
|
||
|
||
### Phase Completion Checklist
|
||
|
||
- [ ] All 8 stateful templates migrated
|
||
- [ ] All manual DOM manipulation removed (121 → 0 instances)
|
||
- [ ] All templates build successfully: `templ generate`
|
||
- [ ] TypeScript compiles successfully: `npm run build:ts`
|
||
- [ ] All dropdowns work with smooth transitions
|
||
- [ ] All modals work with smooth transitions
|
||
- [ ] Click-outside behavior works for all dropdowns/modals
|
||
- [ ] No console errors on any page
|
||
- [ ] No regressions in HTMX functionality
|
||
- [ ] All forms submit correctly
|
||
- [ ] Alpine DevTools shows reactive state correctly
|
||
|
||
### Metrics
|
||
|
||
**Before Migration:**
|
||
|
||
- 121 manual DOM manipulations
|
||
- 100 lines in header.ts
|
||
- No reactive state
|
||
- No smooth transitions
|
||
- Manual click-outside handling
|
||
|
||
**After Migration:**
|
||
|
||
- 0 manual DOM manipulations
|
||
- 40 lines in header.ts (60% reduction)
|
||
- Full reactive state with x-data
|
||
- Smooth transitions with x-transition
|
||
- Built-in click-outside with @click.outside
|
||
|
||
### Final Verification
|
||
|
||
```bash
|
||
# 1. Complete build
|
||
npm run build:ts
|
||
templ generate
|
||
go build
|
||
|
||
# 2. Check for remaining manual DOM manipulation
|
||
cd web/src
|
||
grep -rn "classList.toggle\|classList.add.*hidden\|classList.remove.*hidden" *.ts | wc -l
|
||
# Expected: 0-5 (legitimate use cases only)
|
||
|
||
# 3. Run application
|
||
go run .
|
||
|
||
# 4. Test all pages
|
||
# - Navigate through all major pages
|
||
# - Test all dropdowns
|
||
# - Test all modals
|
||
# - Test all forms
|
||
|
||
# 5. Check Alpine DevTools
|
||
# - Install browser extension
|
||
# - Inspect reactive state
|
||
# - Verify state changes correctly
|
||
```
|
||
|
||
---
|
||
|
||
## Appendix: Complete Code Examples
|
||
|
||
### A.1: Complete header.templ (After Migration)
|
||
|
||
```templ
|
||
package templates
|
||
|
||
templ Header(user User, currentPath string) {
|
||
<nav class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);">
|
||
<div class="w-full px-4 sm:px-6 lg:px-8">
|
||
<div class="flex justify-between items-center h-16">
|
||
<!-- Left: App Title & Navigation -->
|
||
<div class="flex items-center space-x-6">
|
||
<a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity" style="color: var(--text-primary); text-decoration: none;">
|
||
📚 Bookhoard
|
||
</a>
|
||
<div class="hidden md:flex items-center space-x-4">
|
||
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||
Library
|
||
</a>
|
||
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||
Collections
|
||
</a>
|
||
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||
Progress
|
||
</a>
|
||
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||
Devices
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<!-- Center: Search Box -->
|
||
<div class="flex-1 max-w-2xl mx-8">
|
||
<div class="relative">
|
||
<input
|
||
type="text"
|
||
id="header-search"
|
||
placeholder="Search your library..."
|
||
class="w-full px-4 py-2 pl-10 border rounded-lg"
|
||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||
autocomplete="off"
|
||
/>
|
||
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<!-- Right: Theme Switcher & User Menu -->
|
||
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }">
|
||
<!-- Theme Switcher -->
|
||
<div class="relative">
|
||
<button @click="themeDropdownOpen = !themeDropdownOpen"
|
||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||
</svg>
|
||
</button>
|
||
<div x-show="themeDropdownOpen"
|
||
@click.outside="themeDropdownOpen = false"
|
||
x-transition:enter="transition ease-out duration-200"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-150"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
|
||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
|
||
<div class="p-4">
|
||
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
|
||
<div class="space-y-2">
|
||
<button @click="changeTheme('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="changeTheme('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="changeTheme('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="changeTheme('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="changeTheme('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="changeTheme('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="changeTheme('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="changeWoodPaneling('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="changeWoodPaneling('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="changeWoodPaneling('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="changeWoodPaneling('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="logout(); userMenuOpen = false"
|
||
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
|
||
style="color: var(--text-primary); background-color: var(--bg-secondary);">
|
||
Logout
|
||
</button>
|
||
</div>
|
||
</div>
|
||
} else {
|
||
<!-- LOGGED OUT: Show login form -->
|
||
<button @click="userMenuOpen = !userMenuOpen"
|
||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||
</svg>
|
||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
|
||
</button>
|
||
<div x-show="userMenuOpen"
|
||
@click.outside="userMenuOpen = false"
|
||
x-transition
|
||
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4"
|
||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
|
||
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
|
||
<input type="hidden" name="redirect" value="{ currentPath }"/>
|
||
<div>
|
||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
|
||
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
|
||
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||
</div>
|
||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||
Sign In
|
||
</button>
|
||
</form>
|
||
<div id="login-result"></div>
|
||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
|
||
Don't have an account? Sign Up
|
||
</a>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
<script src="/static/main.js"></script>
|
||
}
|
||
```
|
||
|
||
### A.2: Complete header.ts (After Migration)
|
||
|
||
```typescript
|
||
// Header functionality
|
||
|
||
import { Alpine } from "./alpine";
|
||
import { applyTheme } from "./theme";
|
||
import { updateThemeIndicators } from "./themeDropdown";
|
||
|
||
const changeThemeTo = (theme: string): void => {
|
||
// Apply the theme using the consolidated function from theme.ts
|
||
applyTheme(theme);
|
||
|
||
// Save to server if logged in
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
fetch("/api/auth/theme", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({ theme }),
|
||
}).catch((err) => console.log("Theme save failed", err));
|
||
}
|
||
|
||
// Alpine closes dropdown automatically via template state
|
||
};
|
||
|
||
const logout = (): void => {
|
||
localStorage.removeItem("token");
|
||
localStorage.removeItem("user");
|
||
window.location.href = "/";
|
||
};
|
||
|
||
export { changeThemeTo, logout };
|
||
|
||
Alpine.global("header", {
|
||
logout,
|
||
changeThemeTo: (theme: string) => {
|
||
changeThemeTo(theme);
|
||
updateThemeIndicators();
|
||
},
|
||
});
|
||
```
|
||
|
||
### A.3: Alpine.store Pattern for Modals
|
||
|
||
**TypeScript (web/src/collections.ts):**
|
||
|
||
```typescript
|
||
import { Alpine } from "./alpine";
|
||
|
||
// Create global modal store
|
||
Alpine.store("modals", {
|
||
addBooks: false,
|
||
editCollection: false,
|
||
|
||
showAddBooks() {
|
||
this.addBooks = true;
|
||
},
|
||
|
||
hideAddBooks() {
|
||
this.addBooks = false;
|
||
},
|
||
|
||
showEditCollection() {
|
||
this.editCollection = true;
|
||
},
|
||
|
||
hideEditCollection() {
|
||
this.editCollection = false;
|
||
},
|
||
});
|
||
|
||
// Business logic functions (no DOM manipulation)
|
||
async function addSelectedBooks() {
|
||
const token = localStorage.getItem("token");
|
||
// ... API call logic ...
|
||
}
|
||
|
||
async function searchBooksForCollections(query: string) {
|
||
// ... search logic ...
|
||
}
|
||
|
||
export { addSelectedBooks, searchBooksForCollections };
|
||
```
|
||
|
||
**Template (templates/collections.templ):**
|
||
|
||
```templ
|
||
<!-- Button to open modal -->
|
||
<button @click="$store.modals.showAddBooks()" class="btn-primary">
|
||
➕ Add Books
|
||
</button>
|
||
|
||
<!-- Modal -->
|
||
<div x-show="$store.modals.addBooks"
|
||
x-transition
|
||
@click.self="$store.modals.hideAddBooks()"
|
||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
|
||
|
||
<div @click.stop class="card rounded-lg p-6">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h2>Add Books to Collection</h2>
|
||
<button @click="$store.modals.hideAddBooks()">✕</button>
|
||
</div>
|
||
|
||
<!-- Modal content -->
|
||
<input type="text" placeholder="Search books...">
|
||
<button @click="addSelectedBooks()">Add Selected</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
## Next Steps
|
||
|
||
### Recommended Order
|
||
|
||
1. **Read `SSR_FIRST_ALPINE_GUIDE.md` first**
|
||
- Understand SSR-first architecture
|
||
- Learn page type classifications
|
||
- Know when to fetch data
|
||
|
||
2. **Fix immediate console errors** (if needed)
|
||
- See this guide, Phase 0: Dead Export Removal
|
||
- Remove dead exports
|
||
- Clean up DOMContentLoaded listeners
|
||
- Verify builds work
|
||
|
||
3. **Start with header.templ migration** (this guide, Phase 1)
|
||
- Highest priority (used in 17 templates)
|
||
- Reference implementation for all other templates
|
||
- Learn the pattern
|
||
|
||
4. **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
|
||
|
||
5. **Complete remaining templates** (page-by-page)
|
||
- Use header.templ as reference
|
||
- Test thoroughly after each migration
|
||
- Commit frequently with detailed messages
|
||
|
||
6. **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
|