docs: remove obsolete implementation plans

Remove implementation plans that have been completed and are no longer needed:
- ALPINE_COMPLETION_GUIDE.md (95% complete, only docs updates needed)
- BOOK_PICKER_IMPL.md (90% obsolete, better approach implemented)
- SSR_FIRST_ALPINE_GUIDE.md (100% compliant with current implementation)

These plans served their purpose during implementation. Their content lives on
in git history for reference. Keeping the repository clean of outdated planning docs.
This commit is contained in:
2026-03-20 22:57:58 -04:00
parent 09449eff39
commit e82d2d7066
3 changed files with 0 additions and 3212 deletions
File diff suppressed because it is too large Load Diff
-211
View File
@@ -1,211 +0,0 @@
# Book Picker Implementation Plan
## Overview
Implement a client-side book picker modal for collections that allows users to search and select books to add to a collection.
**Architecture:** Client-side only (Alpine.js + TypeScript + HTMX for submission)
- No SSR for book grid
- Uses existing `/api/media-items` and `/api/media-items/search` APIs
- Alpine manages checkbox state and rendering
## Current State
### What's Done ✅
- Collections "Add Books" button enabled
- Book picker modal HTML in template
- Alpine state management in collections.ts
- Icon picker fix (showAllIcons, setupHTMXModalInit)
### What's Broken ❌
- Book picker modal has HTMX-based search that calls non-existent endpoint
- Checkboxes not rendered (API returns JSON, not HTML with checkboxes)
## Implementation
### Phase 1: Update collections.ts
Add the following methods to `Alpine.data("collections", ...)`:
```typescript
// State
bookPickerPage: number = 0,
bookPickerHasMore: boolean = true,
bookPickerLoading: boolean = false,
// Load books from API
async loadBooksForPicker(reset: boolean = false): Promise<void> {
if (reset) {
this.bookPickerPage = 0;
this.bookPickerSelected = [];
}
this.bookPickerLoading = true;
const token = localStorage.getItem("token");
if (!token) return;
try {
const offset = this.bookPickerPage * 50;
const response = await fetch(`/api/media-items?limit=50&offset=${offset}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const result = await response.json();
const items = result.data || [];
this.renderBooksGrid(items, reset);
this.bookPickerHasMore = items.length === 50;
}
} catch (error) {
console.error("Failed to load books:", error);
} finally {
this.bookPickerLoading = false;
}
},
// Search books
async searchBooks(query: string): Promise<void> {
if (query.length < 2) {
if (query.length === 0) {
this.loadBooksForPicker(true);
}
return;
}
this.bookPickerLoading = true;
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(`/api/media-items/search?q=${encodeURIComponent(query)}&limit=50`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const items = await response.json();
this.renderBooksGrid(items, true);
this.bookPickerHasMore = false;
}
} catch (error) {
console.error("Failed to search books:", error);
} finally {
this.bookPickerLoading = false;
}
},
// Render books grid with checkboxes
renderBooksGrid(items: any[], reset: boolean): void {
const grid = document.getElementById("book-picker-grid");
if (!grid) return;
const html = items.map((item: any) => `
<div class="book-item flex gap-3 p-2 border-b" style="border-color: var(--border);">
<input
type="checkbox"
value="${item.id}"
${this.bookPickerSelected.includes(item.id) ? "checked" : ""}
@change="toggleBookPickerBook('${item.id}')"
class="w-5 h-5"
/>
<div class="flex-1 min-w-0">
<p class="font-medium truncate" style="color: var(--text-primary);">${item.title}</p>
<p class="text-sm truncate" style="color: var(--text-secondary);">${item.author || "Unknown"}</p>
</div>
</div>
`).join("");
if (reset) {
grid.innerHTML = html;
} else {
grid.innerHTML += html;
}
},
// Pagination
loadMoreBooks(): void {
if (this.bookPickerLoading || !this.bookPickerHasMore) return;
this.bookPickerPage++;
this.loadBooksForPicker(false);
},
```
### Phase 2: Update collections.templ
Update the modal to use Alpine methods:
1. **Search input** - Change from HTMX to Alpine:
```templ
<input
type="text"
placeholder="Search books..."
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
@input.debounce.300ms="searchBooks($el.value)"
/>
```
2. **Books grid** - Remove hx-get, add Alpine rendering:
```templ
<div
id="book-picker-grid"
class="grid grid-cols-1 gap-2 p-4 max-h-96 overflow-y-auto"
x-init="loadBooksForPicker(true)"
>
<!-- Alpine renders books here -->
</div>
```
3. **Pagination button**:
```templ
<button
x-show="bookPickerHasMore"
@click="loadMoreBooks()"
x-text="bookPickerLoading ? 'Loading...' : 'Load More'"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
></button>
```
4. **Clear search button**:
```templ
<button
@click="loadBooksForPicker(true); $el.previousElementSibling.value = ''"
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Clear
</button>
```
### Phase 3: Verify Submission Works
Ensure the form submission works. The existing form should work:
```templ
<form hx-post={ fmt.Sprintf("/api/collections/%s/books", collection.ID) }
hx-on::after-request="if(event.detail.successful) { closeBookPicker(); htmx.trigger(htmx.find('#books-container'), 'refresh'); }">
```
Verify the endpoint accepts `book_ids` as form data.
## Files Modified
| File | Changes |
|------|---------|
| `web/src/collections.ts` | Add loadBooksForPicker, searchBooks, renderBooksGrid, loadMoreBooks methods |
| `templates/collections.templ` | Update modal to use Alpine methods instead of HTMX for search |
## Testing Checklist
- [ ] Modal opens when clicking "Add Books"
- [ ] Books load on modal open (initial load)
- [ ] Search filters books correctly
- [ ] Clear button resets to all books
- [ ] Checkboxes can be selected/deselected
- [ ] Selected count updates correctly
- [ ] "Load More" loads additional books
- [ ] Form submission adds books to collection
- [ ] Modal closes after successful submission
- [ ] Collection books list refreshes after add
-627
View File
@@ -1,627 +0,0 @@
# SSR-First Alpine.js Architecture Guide
## Overview
This guide ensures Alpine.js integration **maintains SSR-first architecture** while providing interactive features.
**Goal:** Use Alpine.js for interactivity WITHOUT replacing SSR content on page load.
**Complementary to:** `ALPINE_COMPLETION_GUIDE.md` (which covers eliminating manual DOM manipulation)
---
## Table of Contents
1. [SSR-First Principles](#ssr-first-principles)
2. [Page Type Classifications](#page-type-classifications)
3. [The SSR Data Fetch Problem](#the-ssr-data-fetch-problem)
4. [DOMContentLoaded Cleanup](#domcontentloaded-cleanup)
5. [Page-by-Page Strategy](#page-by-page-strategy)
6. [Authentication & SSR](#authentication--ssr)
7. [Verification](#verification)
---
## SSR-First Principles
### Core Rule
**❌ NEVER fetch data in x-init if the data is already SSR'd**
### What Each Layer Does
| Layer | Responsibility |
|-------|---------------|
| **Backend (Go)** | SSR initial page load with real data |
| **Template (.templ)** | Render SSR data, define UI state with `x-data` |
| **Alpine.js** | Manage UI state (modals, dropdowns, transitions) |
| **TypeScript** | Pure business logic (API calls, data processing) |
### Three Page Types
1. **80% SSR Pages** (most pages)
- Backend provides initial data
- Alpine handles modals/dropdowns only
- x-init NEVER fetches data
2. **SSR + Interactive Pages** (dashboard, bookshelf)
- Backend provides initial data
- Alpine handles interactivity (drag-drop, filtering)
- x-init ONLY sets up event listeners, never fetches
3. **80% JavaScript Pages** (analytics)
- Backend renders empty shell
- x-init fetches ALL data on page load
- Exception to the rule (intentional design)
---
## Page Type Classifications
### Type 1: 80% SSR Pages (Most Pages)
**Examples:** Collections, Conflicts, Queue, Devices, Profile
**Characteristics:**
- Full SSR data from backend
- Alpine for modals/dropdowns only
- No data fetch in x-init
**Template Pattern:**
```templ
<body class="theme-{ user.Theme }">
@Header(user, currentPath)
<!-- SSR data rendered here -->
<div>{ collections }</div>
<!-- Alpine manages modal state only -->
<div x-data="{ modalOpen: false }">
<button @click="modalOpen = true">Open</button>
<div x-show="modalOpen" x-transition>Modal content</div>
</div>
</body>
```
**TypeScript:**
```typescript
// Business logic only - no UI state
async function deleteCollection(id: string) {
await apiDelete(`/collections/${id}`);
}
Alpine.data("collections", () => ({
deleteCollection, // Business logic only
// NO modal state - template handles it
}));
```
### Type 2: SSR + Interactive Pages
**Examples:** Dashboard, Bookshelf, Admin Library
**Characteristics:**
- Backend provides initial data
- Alpine manages complex interactivity
- x-init sets up event listeners ONLY
**Dashboard Template Pattern:**
```templ
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<!-- SSR data from backend -->
{ sections }
<!-- Alpine handles drag-drop, library switching -->
<div x-data="dashboard">
<!-- Drag-drop areas -->
<!-- Library selector (triggers data fetch on change, not init) -->
</div>
</body>
```
**Dashboard TypeScript:**
```typescript
// ❌ WRONG - fetches data on page load, replaces SSR
function initDashboard() {
fetch('/api/dashboard/sections').then(renderDashboard);
}
// ✅ CORRECT - sets up event listeners only
function initDashboard() {
initDragAndDrop(); // Setup event listeners
setupLibrarySelect(); // Setup event listener for library switching
// NO data fetch - SSR provides initial data
}
Alpine.data("dashboard", () => ({
initDashboard,
}));
```
### Type 3: 80% JavaScript Pages (Exception)
**Examples:** Analytics
**Characteristics:**
- Backend renders empty shell
- x-init fetches ALL data
- This is intentional - analytics is a dynamic dashboard
**Analytics Template Pattern:**
```templ
<body x-data="analytics" x-init="loadAnalytics" class="theme-{ user.Theme }">
@Header(user, "/analytics")
<!-- Empty containers - JavaScript fills them -->
<div id="reading-stats"></div>
<div id="device-usage"></div>
</body>
```
**Analytics TypeScript:**
```typescript
// ✅ CORRECT - analytics is 80% JS by design
async function loadAnalytics() {
const [statsRes, devicesRes] = await Promise.all([
fetch("/api/analytics/stats"),
fetch("/api/analytics/devices"),
]);
renderReadingStats(await statsRes.json());
renderDeviceUsage(await devicesRes.json());
}
Alpine.data("analytics", () => ({
loadAnalytics,
}));
```
---
## The SSR Data Fetch Problem
### The Bug
**❌ BUG:** x-init fetches data and replaces SSR content
```typescript
// ❌ WRONG - replaces SSR content on page load
function initializeAdmin() {
fetch('/api/libraries').then(renderLibraries); // BUG!
}
```
```html
<!-- ❌ WRONG - SSR content gets replaced -->
<body x-data="library" x-init="initializeAdmin">
<!-- Backend rendered this: -->
{ libraries }
<!-- But x-init fetches and replaces it! -->
</body>
```
### The Fix
**✅ Solution:** Remove data fetch from x-init
```typescript
// ✅ CORRECT - no data fetch
function initializeAdmin() {
setupEventListeners(); // Setup only
}
// Keep fetch for AFTER CRUD operations
async function reloadLibraries() {
fetch('/api/libraries').then(renderLibraries); // OK after create/delete
}
```
```html
<!-- ✅ CORRECT - SSR content stays -->
<body x-data="library" x-init="initializeAdmin">
<!-- Backend rendered this, it stays: -->
{ libraries }
<!-- x-init only sets up event listeners -->
</body>
```
### When to Fetch Data
**OK to fetch in x-init:**
- Analytics pages (Type 3)
- Empty pages that need data
- User-driven navigation (not initial page load)
**NOT OK to fetch in x-init:**
- Pages with SSR data (Types 1 & 2)
- Data that backend already provided
- Replacing SSR content on page load
**OK to fetch AFTER user action:**
- After create/delete/update operations
- After dropdown selection
- After form submission
---
## DOMContentLoaded Cleanup
### 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
```
### 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)
- **Migration:** See `ALPINE_COMPLETION_GUIDE.md` for full migration path
---
## Page-by-Page Strategy
### Dashboard
**Type:** SSR + Interactive
**Current Issues:**
- Has DOMContentLoaded (needs wrapper)
- Has localStorage redirect logic
**Solution:**
```typescript
// ✅ Wrap existing logic in initDashboard()
function initDashboard() {
initDragAndDrop(); // Setup drag-drop
document.addEventListener("click", handleDashboardClick); // Event delegation
// Check localStorage for saved library
const savedLibrary = localStorage.getItem("selectedLibrary");
if (savedLibrary && savedLibrary !== currentLibrary) {
window.location.href = `/dashboard?library_id=${savedLibrary}`;
}
}
Alpine.data("dashboard", () => ({
initDashboard,
}));
```
```html
<!-- templates/dashboard.templ -->
<body x-data="dashboard" x-init="initDashboard" class="theme-{ user.Theme }">
```
### Admin Library
**Type:** SSR + Interactive
**Current Issues:**
- Has `x-init="initializeLibraryAdmin"` which calls `reloadLibraries()`
- This fetches data and replaces SSR content
**Solution:**
```typescript
// ❌ REMOVE this:
function initializeLibraryAdmin() {
setupEventListeners();
void reloadLibraries(); // BUG - fetches data!
}
// ✅ CORRECT:
function initializeLibraryAdmin() {
setupEventListeners(); // Setup only
// No data fetch
}
// Keep for after CRUD operations:
async function reloadLibraries() {
const libraries = await apiGet("/libraries");
renderLibraries(libraries.data); // OK after create/delete
}
```
### Collections
**Type:** 80% SSR
**Current State:** Dead exports removed (see `COLLECTIONS_CLEANUP_GUIDE.md`)
**Solution:**
- No x-init needed
- Use Alpine.store for modal state (see `ALPINE_COMPLETION_GUIDE.md`)
- Business logic functions only in TypeScript
### Analytics
**Type:** 80% JavaScript
**Current State:** Already correct
**Solution:**
- Keep `x-init="loadAnalytics"`
- Data fetch is intentional (analytics is dynamic)
### Docs
**Type:** 80% SSR
**Current State:** Has DOMContentLoaded
**Solution:**
```typescript
// ✅ Simple setup only
function initializeDocsSearch() {
const searchInput = document.getElementById("docs-search");
searchInput?.addEventListener("input", handleDocsSearchInput);
}
Alpine.data("docs", () => ({
initializeDocsSearch,
}));
```
```html
<!-- templates/docs.templ -->
<body x-data="docs" x-init="initializeDocsSearch" class="theme-{ user.Theme }">
```
### Search (in Header)
**Type:** Special case
**Current Issues:**
- Has DOMContentLoaded that runs on ALL pages (via main.ts import)
- Uses localStorage for token (not SSR-friendly)
**Future:** User plans to revamp search
**Current Solution:**
- Leave as-is for now
- Revisit when search is redesigned
- Consider moving token to SSR (see Authentication section below)
---
## Authentication & SSR
### Problem: Token in localStorage
```typescript
// ❌ Current - client-side token
const token = localStorage.getItem("token");
fetch("/api/libraries", {
headers: { Authorization: `Bearer ${token}` },
});
```
**Issues:**
- Not SSR-friendly
- Requires client-side storage
- Fails if JS disabled
### Solution: Server-Side Token Injection
**Backend:** Extract token from HttpOnly cookie
```go
// internal/router/helpers.go
func getTemplateUserWithTheme(c echo.Context, cfg *config.Config) (templates.User, error) {
user := getUserFromSession(c)
// Extract JWT from HttpOnly cookie
token := ""
for _, cookie := range c.Cookies() {
if cookie.Name == "token" {
token = cookie.Value
break
}
}
return templates.User{
ID: user.ID,
Username: user.Username,
Token: token, // Add token to user struct
Theme: user.Theme,
}, nil
}
```
**Template:** Inject token into WebSocket URL
```templ
<!-- templates/admin.templ -->
<script>
const ws = new WebSocket(`ws://localhost:8765/ws/sync?token={ user.Token }`);
</script>
```
**Benefits:**
- ✅ SSR-compatible
- ✅ No localStorage needed
- ✅ Works with HttpOnly cookies
- ✅ More secure
---
## Verification
### Checklist for Each Page
**Type 1 (80% SSR):**
- [ ] Backend provides all data
- [ ] x-init does NOT fetch data
- [ ] x-init only sets up event listeners (if needed)
- [ ] Modals use Alpine.store or local x-data
**Type 2 (SSR + Interactive):**
- [ ] Backend provides initial data
- [ ] x-init does NOT fetch data on page load
- [ ] Data fetch only after user action (dropdown change, button click)
- [ ] Event listeners set up in x-init
**Type 3 (80% JS):**
- [ ] Backend renders empty shell
- [ ] x-init fetches ALL data on page load
- [ ] This is intentional and documented
### Testing
```bash
# 1. Start application
go run .
# 2. Open browser DevTools
# Network tab -> Disable cache
# 3. Load dashboard
# Expected: /dashboard HTML response contains full data
# Expected: NO /api/dashboard/sections call on page load
# 4. Change library dropdown
# Expected: /api/dashboard/sections?library_id=XXX call
# Expected: Content updates
# 5. Load analytics
# Expected: /analytics HTML response is empty shell
# Expected: /api/analytics/stats call on page load
# Expected: /api/analytics/devices call on page load
```
### Common Bugs to Check
**❌ SSR content flashes, then gets replaced:**
- x-init is fetching data
- Remove data fetch from x-init
**❌ API call on page load for SSR page:**
- x-init or DOMContentLoaded is calling fetch
- Move fetch to after user action
**❌ Modal not opening:**
- Missing x-data wrapper
- Check Alpine DevTools for state
**❌ Search not working:**
- Check if search input has id="header-search"
- Check if initializeSearch() is called
---
## Summary
### Key Rules
1. **State lives in template** (`x-data`, `x-show`)
2. **UI updates automatically** (Alpine reactivity)
3. **No manual DOM manipulation** in TypeScript
4. **Pure business logic** in TypeScript functions
5. **❌ NEVER fetch data in x-init** if data is SSR'd
6. **✅ OK to fetch** after user action or for Type 3 pages
### Architecture
```
┌─────────────┐
│ Backend │ SSR data
│ (Go) │────────────┐
└─────────────┘ │
┌──────────┐
│ Template │ x-data state
│ (.templ) │────────────┐
└──────────┘ │
┌──────────────┐
│ Alpine.js │ UI state
│ (Reactivity) │
└──────────────┘
┌─────────────┐ Business Logic ┌─────────────┐
│ TypeScript │─────────────────│ Browser APIs │
│ (API only) │ │ (fetch, etc) │
└─────────────┘ └─────────────┘
```
### Related Guides
- **`ALPINE_COMPLETION_GUIDE.md`** - Eliminate manual DOM manipulation
- **`COLLECTIONS_CLEANUP_GUIDE.md`** - Fix dead exports and DOMContentLoaded issues
- **`PROJECT_GUIDELINES.md`** - Project architecture standards
---
## Next Steps
1. **Classify each page** as Type 1, 2, or 3
2. **Remove data fetches** from x-init on Type 1 & 2 pages
3. **Move token to SSR** (Authentication section)
4. **Test each page** to verify SSR content is not replaced
5. **Document exceptions** (Type 3 pages)
**Remember:** SSR-first means backend provides the truth, Alpine handles the interaction.