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)
2117 lines
64 KiB
Markdown
2117 lines
64 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
|
||
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`
|
||
- `toggleBookSelection is not defined`
|
||
|
||
These must be fixed before attempting full migration.
|
||
|
||
---
|
||
|
||
### 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: Remove Dead Function Calls from Collection Detail Page
|
||
|
||
**Line ~226:** Remove the `removebooksToAdd` call:
|
||
|
||
```html
|
||
<!-- 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:
|
||
|
||
```html
|
||
<!-- 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
|
||
|
||
```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
|
||
```
|
||
|
||
2. **Update export statement** to remove dead functions
|
||
|
||
3. **Update Alpine.data registration** to remove dead functions
|
||
|
||
4. **Update templates** to remove dead function calls
|
||
|
||
5. **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="header.changeThemeTo('tokyo-night'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
```
|
||
|
||
**Key Changes:**
|
||
1. ✅ Namespace added: `header.changeThemeTo('tokyo-night')`
|
||
2. ✅ Close dropdown after selection: `themeDropdownOpen = false`
|
||
|
||
**Update wood paneling buttons (lines 87, 96, 108, 120):**
|
||
|
||
Replace:
|
||
```templ
|
||
<button @click="changeWoodPaneling('none')" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary);" data-wood="none">
|
||
```
|
||
|
||
With:
|
||
```templ
|
||
<button @click="woodPaneling.change('none'); themeDropdownOpen = false"
|
||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary);"
|
||
data-wood="none">
|
||
```
|
||
|
||
**Note**: We'll need to add `woodPaneling` namespace in Alpine registration (Step 1.3).
|
||
|
||
**Update user menu section (lines 136-160):**
|
||
|
||
Replace:
|
||
```templ
|
||
<div class="relative">
|
||
<button @click="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||
</svg>
|
||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
|
||
</button>
|
||
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
|
||
```
|
||
|
||
With:
|
||
```templ
|
||
<div class="relative">
|
||
<button @click="userMenuOpen = !userMenuOpen"
|
||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||
</svg>
|
||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
|
||
</button>
|
||
<div x-show="userMenuOpen"
|
||
@click.outside="userMenuOpen = false"
|
||
x-transition
|
||
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
|
||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
|
||
```
|
||
|
||
**Update logout button (line 156):**
|
||
|
||
Replace:
|
||
```templ
|
||
<button @click="logout()" class="block w-full text-left px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary);">
|
||
Logout
|
||
</button>
|
||
```
|
||
|
||
With:
|
||
```templ
|
||
<button @click="header.logout(); userMenuOpen = false"
|
||
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
|
||
style="color: var(--text-primary); background-color: var(--bg-secondary);">
|
||
Logout
|
||
</button>
|
||
```
|
||
|
||
**Complete header.templ changes summary:**
|
||
- ✅ Wrapped nav in `x-data="{ themeDropdownOpen: false, userMenuOpen: false }"`
|
||
- ✅ Replaced all `@click="toggleXxx()"` with state toggles
|
||
- ✅ Replaced `id="xxx"` + `class="hidden"` with `x-show="xxxOpen"`
|
||
- ✅ Added `@click.outside` to both dropdowns
|
||
- ✅ Added `x-transition` for smooth animations
|
||
- ✅ Added namespace calls: `header.changeThemeTo()`, `header.logout()`
|
||
- ✅ Close dropdowns after action: `themeDropdownOpen = false`
|
||
|
||
### Step 1.2: Update header.ts
|
||
|
||
**Location**: `web/src/header.ts`
|
||
**Lines to delete**: 7-31, 64-88
|
||
|
||
**Delete manual DOM manipulation functions:**
|
||
|
||
```typescript
|
||
// DELETE lines 7-31:
|
||
const toggleThemeDropdown = (): void => {
|
||
const dropdown = document.getElementById("theme-dropdown");
|
||
if (dropdown) {
|
||
dropdown.classList.toggle("hidden");
|
||
const userMenu = document.getElementById("user-menu");
|
||
if (userMenu && !dropdown.classList.contains("hidden")) {
|
||
userMenu.classList.add("hidden");
|
||
}
|
||
}
|
||
};
|
||
|
||
const toggleUserMenu = (): void => {
|
||
const menu = document.getElementById("user-menu");
|
||
if (menu) {
|
||
menu.classList.toggle("hidden");
|
||
const themeDropdown = document.getElementById("theme-dropdown");
|
||
if (themeDropdown && !menu.classList.contains("hidden")) {
|
||
themeDropdown.classList.add("hidden");
|
||
}
|
||
}
|
||
};
|
||
```
|
||
|
||
**Simplify changeThemeTo function (lines 33-55):**
|
||
|
||
**Before:**
|
||
```typescript
|
||
const changeThemeTo = (theme: string): void => {
|
||
applyTheme(theme);
|
||
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
fetch("/api/auth/theme", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({ theme }),
|
||
}).catch((err) => console.log("Theme save failed", err));
|
||
}
|
||
|
||
// Close dropdown ← Manual DOM manipulation!
|
||
const dropdown = document.getElementById("theme-dropdown");
|
||
if (dropdown) {
|
||
dropdown.classList.add("hidden");
|
||
}
|
||
};
|
||
```
|
||
|
||
**After:**
|
||
```typescript
|
||
const changeThemeTo = (theme: string): void => {
|
||
// Apply theme
|
||
applyTheme(theme);
|
||
|
||
// Save to server if logged in
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
fetch("/api/auth/theme", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({ theme }),
|
||
}).catch((err) => console.log("Theme save failed", err));
|
||
}
|
||
|
||
// No dropdown manipulation - Alpine handles it via template state!
|
||
};
|
||
```
|
||
|
||
**Delete click-outside event listener (lines 64-88):**
|
||
|
||
```typescript
|
||
// DELETE entire event listener - Alpine's @click.outside handles this:
|
||
document.addEventListener("click", (e) => {
|
||
const target = e.target as HTMLElement;
|
||
const themeDropdown = document.getElementById("theme-dropdown");
|
||
const userMenu = document.getElementById("user-menu");
|
||
const themeButton = target?.closest(
|
||
'button[onclick="toggleThemeDropdown()"]',
|
||
);
|
||
const userButton = target?.closest('button[onclick="toggleUserMenu()"]');
|
||
|
||
if (
|
||
!themeButton &&
|
||
themeDropdown &&
|
||
!themeDropdown.classList.contains("hidden")
|
||
) {
|
||
if (!themeDropdown.contains(target)) {
|
||
themeDropdown.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
if (!userButton && userMenu && !userMenu.classList.contains("hidden")) {
|
||
if (!userMenu.contains(target)) {
|
||
userMenu.classList.add("hidden");
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
**Final header.ts (after cleanup):**
|
||
|
||
```typescript
|
||
// Header functionality
|
||
|
||
import { Alpine } from "./alpine";
|
||
import { applyTheme } from "./theme";
|
||
import { updateThemeIndicators } from "./themeDropdown";
|
||
|
||
const changeThemeTo = (theme: string): void => {
|
||
// Apply the theme using the consolidated function from theme.ts
|
||
applyTheme(theme);
|
||
|
||
// Save to server if logged in
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
fetch("/api/auth/theme", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({ theme }),
|
||
}).catch((err) => console.log("Theme save failed", err));
|
||
}
|
||
|
||
// Alpine closes dropdown automatically via template state
|
||
};
|
||
|
||
const logout = (): void => {
|
||
localStorage.removeItem("token");
|
||
localStorage.removeItem("user");
|
||
window.location.href = "/";
|
||
};
|
||
|
||
export { changeThemeTo, logout };
|
||
|
||
Alpine.global("header", {
|
||
logout,
|
||
changeThemeTo: (theme: string) => {
|
||
changeThemeTo(theme);
|
||
updateThemeIndicators();
|
||
},
|
||
});
|
||
```
|
||
|
||
**Result**: header.ts reduced from **100 lines to 40 lines** (60% reduction)
|
||
|
||
### Step 1.3: Create woodPaneling.ts Namespace
|
||
|
||
**Location**: `web/src/woodPaneling.ts`
|
||
|
||
**Current**: Functions exist but need Alpine registration for namespace calls
|
||
|
||
**Add to bottom of woodPaneling.ts:**
|
||
|
||
```typescript
|
||
import { Alpine } from "./alpine";
|
||
|
||
// ... existing functions ...
|
||
|
||
Alpine.global("woodPaneling", {
|
||
change: changeWoodPaneling,
|
||
});
|
||
```
|
||
|
||
### Step 1.4: Verify header.templ Migration
|
||
|
||
**Build verification:**
|
||
```bash
|
||
npm run build:ts
|
||
templ generate
|
||
```
|
||
|
||
**Expected**: No errors, templates compile successfully
|
||
|
||
**Manual testing:**
|
||
1. Start application: `go run .`
|
||
2. Navigate to any page with header (all pages)
|
||
3. Test theme dropdown:
|
||
- [ ] Click theme button → dropdown opens with smooth transition
|
||
- [ ] Click outside → dropdown closes
|
||
- [ ] Click theme option → theme changes, dropdown closes
|
||
- [ ] User menu closes if open
|
||
4. Test user menu:
|
||
- [ ] Click user button → menu opens with smooth transition
|
||
- [ ] Click outside → menu closes
|
||
- [ ] Click logout → logout, menu closes
|
||
- [ ] Theme dropdown closes if open
|
||
5. Test wood paneling:
|
||
- [ ] Click wood paneling option → changes, dropdown closes
|
||
|
||
**Debug with Alpine DevTools (optional):**
|
||
```bash
|
||
# Install Alpine DevTools browser extension
|
||
# Open DevTools → Alpine tab
|
||
# Inspect reactive state: themeDropdownOpen, userMenuOpen
|
||
```
|
||
|
||
**Common issues:**
|
||
- ❌ Dropdown doesn't open: Check if `x-data` is on parent container
|
||
- ❌ Dropdown doesn't close: Check if `@click.outside` is on dropdown div
|
||
- ❌ No smooth transition: Check if `x-transition` directives are present
|
||
- ❌ Flash of unstyled content: Verify `style="display: none;"` is on x-show elements
|
||
|
||
---
|
||
|
||
## Phase 2: Modal Templates
|
||
|
||
All modal templates follow the same pattern. Apply consistently.
|
||
|
||
### 2.1: collection_modal.templ
|
||
|
||
**Location**: `templates/collection_modal.templ`
|
||
**Time**: 1 hour
|
||
**Complexity**: Medium (modal with color/icon pickers)
|
||
|
||
**Template Changes:**
|
||
|
||
**Current (line 4):**
|
||
```templ
|
||
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||
```
|
||
|
||
**No change needed** - `x-data="collections"` namespace is correct, but we need local state.
|
||
|
||
**Add local state to wrapper:**
|
||
|
||
Replace line 4:
|
||
```templ
|
||
<div x-data="{ colorModalOpen: false }" x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
|
||
```
|
||
|
||
**Better approach - use Alpine.data():**
|
||
|
||
**In web/src/collections.ts, add:**
|
||
```typescript
|
||
Alpine.data("collections", () => ({
|
||
colorModalOpen: false,
|
||
iconModalOpen: false,
|
||
|
||
openColorModal() {
|
||
this.colorModalOpen = true;
|
||
},
|
||
|
||
closeColorModal() {
|
||
this.colorModalOpen = false;
|
||
},
|
||
|
||
// ... existing methods ...
|
||
}));
|
||
```
|
||
|
||
**Actually, for modals, the simplest approach:**
|
||
|
||
Since this is a modal that's loaded via HTMX, we don't need x-data state. The modal itself is shown/hidden by HTMX.
|
||
|
||
**Skip this template** - it's already using Alpine namespace correctly via HTMX loading.
|
||
|
||
### 2.2: collections.templ
|
||
|
||
**Location**: `templates/collections.templ`
|
||
**Time**: 1-2 hours
|
||
**Complexity**: Medium (add books modal)
|
||
|
||
**Find "add-books-modal" section (lines 227-260):**
|
||
|
||
**Current:**
|
||
```templ
|
||
<div id="add-books-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
|
||
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
|
||
<button @click="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||
</div>
|
||
```
|
||
|
||
**Replace with:**
|
||
```templ
|
||
<div x-data="{ addBooksModalOpen: false }">
|
||
<!-- Button to open modal (line 156) -->
|
||
<button @click="addBooksModalOpen = true" class="btn-primary px-4 py-2 rounded-lg">
|
||
➕ Add Books
|
||
</button>
|
||
|
||
<!-- Modal (line 227) -->
|
||
<div x-show="addBooksModalOpen"
|
||
x-transition
|
||
@click.self="addBooksModalOpen = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
|
||
<div @click.stop
|
||
class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8"
|
||
style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
|
||
<button @click="addBooksModalOpen = false"
|
||
class="p-2 hover:opacity-80 rounded"
|
||
style="color: var(--text-primary)">✕</button>
|
||
</div>
|
||
```
|
||
|
||
**Note**: The modal needs to be wrapped in x-data container that includes the button. Since the button and modal are far apart, we need to restructure.
|
||
|
||
**Better approach - use Alpine.store():**
|
||
|
||
**In web/src/collections.ts, add:**
|
||
```typescript
|
||
// Create a global store for modal state
|
||
Alpine.store('modals', {
|
||
addBooks: false,
|
||
showAddBooks() {
|
||
this.addBooks = true;
|
||
},
|
||
hideAddBooks() {
|
||
this.addBooks = false;
|
||
}
|
||
});
|
||
```
|
||
|
||
**In template, use:**
|
||
```templ
|
||
<!-- Button (line 156) -->
|
||
<button @click="$store.modals.showAddBooks()" class="btn-primary px-4 py-2 rounded-lg">
|
||
➕ Add Books
|
||
</button>
|
||
|
||
<!-- Modal (line 227) -->
|
||
<div x-show="$store.modals.addBooks"
|
||
x-transition
|
||
@click.self="$store.modals.hideAddBooks()"
|
||
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
|
||
```
|
||
|
||
**Remove from TypeScript:**
|
||
|
||
In `web/src/collections.ts`, delete:
|
||
```typescript
|
||
function showAddBooksModal() { /* ... */ }
|
||
function hideAddBooksModal() { /* ... */ }
|
||
```
|
||
|
||
**Keep only business logic:**
|
||
```typescript
|
||
async function addSelectedBooks() { /* API call */ }
|
||
async function searchBooksForCollections() { /* API call */ }
|
||
```
|
||
|
||
### 2.3: conflicts.templ
|
||
|
||
**Location**: `templates/conflicts.templ`
|
||
**Time**: 1 hour
|
||
**Complexity**: Medium (resolution modal)
|
||
|
||
**Use Alpine.store pattern (same as collections):**
|
||
|
||
**In web/src/conflicts.ts, add:**
|
||
```typescript
|
||
Alpine.store('modals', {
|
||
resolveConflict: false,
|
||
showResolveConflict() {
|
||
this.resolveConflict = true;
|
||
},
|
||
hideResolveConflict() {
|
||
this.resolveConflict = false;
|
||
}
|
||
});
|
||
```
|
||
|
||
**Template changes:**
|
||
|
||
**Current (line 123):**
|
||
```templ
|
||
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
|
||
```
|
||
|
||
**Replace with:**
|
||
```templ
|
||
<div x-show="$store.modals.resolveConflict"
|
||
x-transition
|
||
@click.self="$store.modals.hideResolveConflict()"
|
||
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
|
||
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
|
||
```
|
||
|
||
**Update button (line 110):**
|
||
|
||
Replace:
|
||
```templ
|
||
<button @click="showConflictModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
|
||
```
|
||
|
||
With:
|
||
```templ
|
||
<button @click="conflicts.showResolve('{ conflict.ID }'); $store.modals.showResolveConflict()"
|
||
class="btn-primary px-4 py-2 rounded-lg">
|
||
```
|
||
|
||
**Remove from TypeScript:**
|
||
Delete `showConflictModal()`, `hideConflictModal()` functions.
|
||
|
||
### 2.4: queue.templ
|
||
|
||
**Location**: `templates/queue.templ`
|
||
**Time**: 1 hour
|
||
**Complexity**: Low (simple action modals)
|
||
|
||
**Use Alpine.store pattern** - same as conflicts.templ
|
||
|
||
### 2.5: admin.templ
|
||
|
||
**Location**: `templates/admin.templ`
|
||
**Time**: 1 hour
|
||
**Complexity**: Medium (scan progress modal)
|
||
|
||
**Use Alpine.store pattern** - same as collections.templ
|
||
|
||
### 2.6: devices.templ
|
||
|
||
**Location**: `templates/devices.templ`
|
||
**Time**: 1 hour
|
||
**Complexity**: Low (token generation modal)
|
||
|
||
**Use Alpine.store pattern** - same as conflicts.templ
|
||
|
||
### 2.7: profile_modal.templ
|
||
|
||
**Location**: `templates/profile_modal.templ`
|
||
**Time**: 1 hour
|
||
**Complexity**: Low (edit profile modal)
|
||
|
||
**Use Alpine.store pattern** - same as conflicts.templ
|
||
|
||
---
|
||
|
||
## Phase 3: 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">
|
||
```
|
||
|
||
#### 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 }">
|
||
```
|
||
|
||
**✅ 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 }">
|
||
```
|
||
|
||
**✅ 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 }">
|
||
```
|
||
|
||
**✅ 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()">
|
||
```
|
||
|
||
**✅ 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);
|
||
```
|
||
|
||
2. **Export the init function:**
|
||
```typescript
|
||
export { initializePage };
|
||
```
|
||
|
||
3. **Add to Alpine.data:**
|
||
```typescript
|
||
Alpine.data("page", () => ({
|
||
initializePage,
|
||
}));
|
||
```
|
||
|
||
4. **Update template:**
|
||
```html
|
||
<!-- BEFORE -->
|
||
<body class="theme-{ user.Theme }">
|
||
|
||
<!-- AFTER -->
|
||
<body x-data="page" x-init="initializePage" class="theme-{ user.Theme }">
|
||
```
|
||
|
||
5. **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="header.changeThemeTo('tokyo-night'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
|
||
Tokyo Night
|
||
</button>
|
||
<button @click="header.changeThemeTo('dracula'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
|
||
Dracula
|
||
</button>
|
||
<button @click="header.changeThemeTo('nord'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
|
||
Nord
|
||
</button>
|
||
<button @click="header.changeThemeTo('solarized-dark'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
|
||
Solarized Dark
|
||
</button>
|
||
<button @click="header.changeThemeTo('monokai'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
|
||
Monokai
|
||
</button>
|
||
<button @click="header.changeThemeTo('one-dark-pro'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
|
||
One Dark Pro
|
||
</button>
|
||
<button @click="header.changeThemeTo('material-dark'); themeDropdownOpen = false"
|
||
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary); background-color: var(--bg-primary);">
|
||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
|
||
Material Dark
|
||
</button>
|
||
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
|
||
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
|
||
<button @click="woodPaneling.change('none'); themeDropdownOpen = false"
|
||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary);"
|
||
data-wood="none">
|
||
None
|
||
</button>
|
||
<button @click="woodPaneling.change('wood-light'); themeDropdownOpen = false"
|
||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary);"
|
||
data-wood="wood-light">
|
||
<span class="inline-block w-4 h-4 rounded mr-2"
|
||
style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
|
||
Wood Light
|
||
</button>
|
||
<button @click="woodPaneling.change('wood-dark'); themeDropdownOpen = false"
|
||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary);"
|
||
data-wood="wood-dark">
|
||
<span class="inline-block w-4 h-4 rounded mr-2"
|
||
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
|
||
Wood Dark
|
||
</button>
|
||
<button @click="woodPaneling.change('wood-mahogany'); themeDropdownOpen = false"
|
||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
|
||
style="color: var(--text-primary);"
|
||
data-wood="wood-mahogany">
|
||
<span class="inline-block w-4 h-4 rounded mr-2"
|
||
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
|
||
Wood Mahogany
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- User Icon with Dropdown -->
|
||
<div class="relative">
|
||
if user.ID != "" {
|
||
<!-- LOGGED IN: Show user menu with logout -->
|
||
<button @click="userMenuOpen = !userMenuOpen"
|
||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||
</svg>
|
||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
|
||
</button>
|
||
<div x-show="userMenuOpen"
|
||
@click.outside="userMenuOpen = false"
|
||
x-transition
|
||
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
|
||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
|
||
<div class="py-1">
|
||
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
|
||
Profile
|
||
</a>
|
||
if user.Role == "admin" {
|
||
<a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
|
||
Admin Panel
|
||
</a>
|
||
}
|
||
<div class="border-t my-1" style="border-color: var(--border);"></div>
|
||
<button @click="header.logout(); userMenuOpen = false"
|
||
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
|
||
style="color: var(--text-primary); background-color: var(--bg-secondary);">
|
||
Logout
|
||
</button>
|
||
</div>
|
||
</div>
|
||
} else {
|
||
<!-- LOGGED OUT: Show login form -->
|
||
<button @click="userMenuOpen = !userMenuOpen"
|
||
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-primary);">
|
||
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||
</svg>
|
||
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
|
||
</button>
|
||
<div x-show="userMenuOpen"
|
||
@click.outside="userMenuOpen = false"
|
||
x-transition
|
||
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4"
|
||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
|
||
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
|
||
<input type="hidden" name="redirect" value="{ currentPath }"/>
|
||
<div>
|
||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
|
||
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
|
||
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||
</div>
|
||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||
Sign In
|
||
</button>
|
||
</form>
|
||
<div id="login-result"></div>
|
||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
|
||
Don't have an account? Sign Up
|
||
</a>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
<script src="/static/main.js"></script>
|
||
}
|
||
```
|
||
|
||
### A.2: Complete header.ts (After Migration)
|
||
|
||
```typescript
|
||
// Header functionality
|
||
|
||
import { Alpine } from "./alpine";
|
||
import { applyTheme } from "./theme";
|
||
import { updateThemeIndicators } from "./themeDropdown";
|
||
|
||
const changeThemeTo = (theme: string): void => {
|
||
// Apply the theme using the consolidated function from theme.ts
|
||
applyTheme(theme);
|
||
|
||
// Save to server if logged in
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
fetch("/api/auth/theme", {
|
||
method: "PUT",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({ theme }),
|
||
}).catch((err) => console.log("Theme save failed", err));
|
||
}
|
||
|
||
// Alpine closes dropdown automatically via template state
|
||
};
|
||
|
||
const logout = (): void => {
|
||
localStorage.removeItem("token");
|
||
localStorage.removeItem("user");
|
||
window.location.href = "/";
|
||
};
|
||
|
||
export { changeThemeTo, logout };
|
||
|
||
Alpine.global("header", {
|
||
logout,
|
||
changeThemeTo: (theme: string) => {
|
||
changeThemeTo(theme);
|
||
updateThemeIndicators();
|
||
},
|
||
});
|
||
```
|
||
|
||
### A.3: Alpine.store Pattern for Modals
|
||
|
||
**TypeScript (web/src/collections.ts):**
|
||
```typescript
|
||
import { Alpine } from "./alpine";
|
||
|
||
// Create global modal store
|
||
Alpine.store('modals', {
|
||
addBooks: false,
|
||
editCollection: false,
|
||
|
||
showAddBooks() {
|
||
this.addBooks = true;
|
||
},
|
||
|
||
hideAddBooks() {
|
||
this.addBooks = false;
|
||
},
|
||
|
||
showEditCollection() {
|
||
this.editCollection = true;
|
||
},
|
||
|
||
hideEditCollection() {
|
||
this.editCollection = false;
|
||
}
|
||
});
|
||
|
||
// Business logic functions (no DOM manipulation)
|
||
async function addSelectedBooks() {
|
||
const token = localStorage.getItem("token");
|
||
// ... API call logic ...
|
||
}
|
||
|
||
async function searchBooksForCollections(query: string) {
|
||
// ... search logic ...
|
||
}
|
||
|
||
export { addSelectedBooks, searchBooksForCollections };
|
||
```
|
||
|
||
**Template (templates/collections.templ):**
|
||
```templ
|
||
<!-- Button to open modal -->
|
||
<button @click="$store.modals.showAddBooks()" class="btn-primary">
|
||
➕ Add Books
|
||
</button>
|
||
|
||
<!-- Modal -->
|
||
<div x-show="$store.modals.addBooks"
|
||
x-transition
|
||
@click.self="$store.modals.hideAddBooks()"
|
||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
|
||
|
||
<div @click.stop class="card rounded-lg p-6">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h2>Add Books to Collection</h2>
|
||
<button @click="$store.modals.hideAddBooks()">✕</button>
|
||
</div>
|
||
|
||
<!-- Modal content -->
|
||
<input type="text" placeholder="Search books...">
|
||
<button @click="addSelectedBooks()">Add Selected</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
## Next Steps
|
||
|
||
### 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
|