refactor: Consolidate script imports to use main.js bundle

- dashboard.templ: Replaced individual script tags with single main.js import
- admin_users.templ, custom_section.templ: Minor formatting/cleanup
- docs.templ: Updated script imports (excluded from Alpine conversion per user request)
- api.ts, docs.ts, password_validation.ts: Minor updates for compatibility
This commit is contained in:
2026-03-08 21:37:17 -04:00
parent c0290bb106
commit 0919698cf4
7 changed files with 568 additions and 510 deletions
+128 -138
View File
@@ -3,143 +3,133 @@ package templates
templ AdminUsers(currentUser User, users []User, adminCount int) { templ AdminUsers(currentUser User, users []User, adminCount int) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<title>Users - Bookhoard Admin</title> <title>Users - Bookhoard Admin</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/header.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<script src="/static/theme.js"></script> </head>
<link href="/static/style.css" rel="stylesheet"> <body class="theme-{ currentUser.Theme }">
</head> @Header(currentUser, "/admin/users")
<body class="theme-{ currentUser.Theme }"> <!-- Modal Container (populated by HTMX) -->
@Header(currentUser, "/admin/users") <div id="modal-container"></div>
<div class="flex min-h-screen" style="background-color: var(--bg-primary)">
<!-- Modal Container (populated by HTMX) --> @AdminSidebar(currentUser, "/admin/users")
<div id="modal-container"></div> <main class="flex-1 p-8">
<div class="w-full">
<div class="flex min-h-screen" style="background-color: var(--bg-primary)"> <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1>
@AdminSidebar(currentUser, "/admin/users") <p style="color: var(--text-secondary)">Manage user accounts and permissions</p>
</div>
<main class="flex-1 p-8"> <!-- Users Table -->
<div class="w-full"> <div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1> <table class="w-full">
<p style="color: var(--text-secondary)">Manage user accounts and permissions</p> <thead style="background-color: var(--bg-primary)">
</div> <tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Username</th>
<!-- Users Table --> <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Email</th>
<div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)"> <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Role</th>
<table class="w-full"> <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Created</th>
<thead style="background-color: var(--bg-primary)"> <th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Actions</th>
<tr> </tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Username</th> </thead>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Email</th> <tbody class="divide-y" style="divide-color: var(--border)">
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Role</th> for _, user := range users {
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Created</th> <tr id={ "user-" + user.ID } class="hover:bg-opacity-50" style="transition: background-color 0.2s;">
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider" style="color: var(--text-secondary)">Actions</th> <!-- Username -->
</tr> <td class="px-6 py-4 whitespace-nowrap">
</thead> <div class="flex items-center">
<tbody class="divide-y" style="divide-color: var(--border)"> <div>
for _, user := range users { <div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
<tr id={ "user-" + user.ID } class="hover:bg-opacity-50" style="transition: background-color 0.2s;"> if user.ID == currentUser.ID {
<!-- Username --> <span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span>
<td class="px-6 py-4 whitespace-nowrap"> }
<div class="flex items-center"> </div>
<div> </div>
<div class="text-sm font-medium" style="color: var(--text-primary)">{user.Username}</div> </td>
if user.ID == currentUser.ID { <!-- Email -->
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: white;">You</span> <td class="px-6 py-4 whitespace-nowrap">
} <div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
</div> </td>
</div> <!-- Role Toggle (with last-admin protection) -->
</td> <td class="px-6 py-4 whitespace-nowrap">
if user.Role == "admin" && adminCount == 1 {
<!-- Email --> <!-- Last admin - disabled -->
<td class="px-6 py-4 whitespace-nowrap"> <div class="relative">
<div class="text-sm" style="color: var(--text-primary)">{user.Email}</div> <select
</td> disabled
class="text-sm rounded px-2 py-1 cursor-not-allowed opacity-50"
<!-- Role Toggle (with last-admin protection) --> style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);"
<td class="px-6 py-4 whitespace-nowrap"> title="Cannot demote the last admin"
if user.Role == "admin" && adminCount == 1 { >
<!-- Last admin - disabled --> <option value="user">User</option>
<div class="relative"> <option value="admin" selected>Admin</option>
<select </select>
disabled </div>
class="text-sm rounded px-2 py-1 cursor-not-allowed opacity-50" } else {
style="background-color: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border);" <select
title="Cannot demote the last admin" hx-put={ "/api/auth/profile/" + user.ID }
> hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
<option value="user">User</option> hx-target={ "#role-result-" + user.ID }
<option value="admin" selected>Admin</option> hx-swap="innerHTML"
</select> name="role"
</div> class="text-sm rounded px-2 py-1"
} else { style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);"
<select onchange="this.dispatchEvent(new Event('htmx:trigger'))"
hx-put={ "/api/auth/profile/" + user.ID } hx-trigger="change"
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' hx-vals='{"role": this.value}'
hx-target={ "#role-result-" + user.ID } >
hx-swap="innerHTML" <option value="user" selected?={ user.Role == "user" }>User</option>
name="role" <option value="admin" selected?={ user.Role == "admin" }>Admin</option>
class="text-sm rounded px-2 py-1" </select>
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);" <div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
onchange="this.dispatchEvent(new Event('htmx:trigger'))" }
hx-trigger="change" </td>
hx-vals='{"role": this.value}' <!-- Created -->
> <td class="px-6 py-4 whitespace-nowrap">
<option value="user" selected?={user.Role == "user"}>User</option> <div class="text-sm" style="color: var(--text-secondary)">{ user.CreatedAt.Format("2006-01-02") }</div>
<option value="admin" selected?={user.Role == "admin"}>Admin</option> </td>
</select> <!-- Actions -->
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div> <td class="px-6 py-4 whitespace-nowrap text-right">
} <button
</td> hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
hx-target="#modal-container"
<!-- Created --> hx-swap="innerHTML"
<td class="px-6 py-4 whitespace-nowrap"> class="text-sm px-3 py-1 rounded mr-2"
<div class="text-sm" style="color: var(--text-secondary)">{ user.CreatedAt.Format("2006-01-02") }</div> style="background-color: var(--accent); color: white;"
</td> >
Edit
<!-- Actions --> </button>
<td class="px-6 py-4 whitespace-nowrap text-right"> if user.Role == "admin" && adminCount == 1 {
<button <button
hx-get={ "/admin/users/" + user.ID + "/profile-modal" } disabled
hx-target="#modal-container" class="text-sm px-3 py-1 rounded cursor-not-allowed opacity-50"
hx-swap="innerHTML" style="background-color: #dc2626; color: white;"
class="text-sm px-3 py-1 rounded mr-2" title="Cannot delete the last admin"
style="background-color: var(--accent); color: white;" >
> Delete
Edit </button>
</button> } else {
if user.Role == "admin" && adminCount == 1 { <button
<button hx-delete={ "/api/auth/profile/" + user.ID }
disabled hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}'
class="text-sm px-3 py-1 rounded cursor-not-allowed opacity-50" hx-target={ "#user-" + user.ID }
style="background-color: #dc2626; color: white;" hx-swap="outerHTML swap:0.5s"
title="Cannot delete the last admin" hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
> class="text-sm px-3 py-1 rounded"
Delete style="background-color: #dc2626; color: white;"
</button> >
} else { Delete
<button </button>
hx-delete={ "/api/auth/profile/" + user.ID } }
hx-headers='{"Authorization": "Bearer " + localStorage.getItem("token")}' </td>
hx-target={ "#user-" + user.ID } </tr>
hx-swap="outerHTML swap:0.5s" }
hx-confirm="Are you sure you want to delete this user? This action cannot be undone." </tbody>
class="text-sm px-3 py-1 rounded" </table>
style="background-color: #dc2626; color: white;" </div>
> </main>
Delete </div>
</button> </body>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</main>
</div>
</body>
</html> </html>
} }
+177 -150
View File
@@ -3,166 +3,193 @@ package templates
templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage string) { templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage string) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Create Custom Section - Bookhoard</title> <title>Create Custom Section - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/api.js"></script> <link href="/static/style.css" rel="stylesheet"/>
<script src="/static/custom-section-builder.js"></script> </head>
<link href="/static/style.css" rel="stylesheet"> <body class="theme-{ user.Theme }">
</head> @Header(user, "/custom-section")
<body class="theme-{ user.Theme }"> <main class="max-w-4xl mx-auto px-4 py-8">
@Header(user, "/custom-section") <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
<p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
<main class="max-w-4xl mx-auto px-4 py-8"> <form id="custom-section-form" class="space-y-6">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1> <!-- Section Details -->
<p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p> <div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
<form id="custom-section-form" class="space-y-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Section Details --> <div>
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);"> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2> <input
type="text"
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> id="section-name"
<div> name="name"
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label> required
<input type="text" id="section-name" name="name" required class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> />
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
<input
type="text"
id="section-icon"
name="icon"
maxlength="4"
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="📚"
/>
</div>
</div> </div>
<div class="mt-4">
<div> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label> <textarea
<input type="text" id="section-icon" name="icon" maxlength="4" id="section-description"
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500" name="description"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" rows="2"
placeholder="📚">
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<textarea id="section-description" name="description" rows="2"
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"></textarea>
</div>
<div class="mt-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
<select id="section-library" name="library_id" required
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500" class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
<option value="">Select a library...</option> ></textarea>
for _, lib := range libraries { </div>
<option value={ lib.ID }>{ lib.Name }</option> <div class="mt-4">
} <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
</select> <select
id="section-library"
name="library_id"
required
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
>
<option value="">Select a library...</option>
for _, lib := range libraries {
<option value={ lib.ID }>{ lib.Name }</option>
}
</select>
</div>
</div> </div>
</div> <!-- Filter Rules -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<!-- Filter Rules --> <div class="flex items-center justify-between mb-4">
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);"> <h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
<div class="flex items-center justify-between mb-4"> <button
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2> type="button"
<button type="button" id="add-rule-btn" id="add-rule-btn"
class="px-3 py-1 rounded-lg text-sm font-medium" class="px-3 py-1 rounded-lg text-sm font-medium"
style="background-color: var(--accent);"> style="background-color: var(--accent);"
+ Add Rule >
</button> + Add Rule
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary)">
Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
</p>
<div id="rules-container" class="space-y-3">
</div>
<div class="mt-4 flex items-center gap-2">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
<select id="match-type" name="match_type"
class="px-3 py-1 rounded border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="all">ALL rules (AND)</option>
<option value="any">ANY rule (OR)</option>
</select>
</div>
</div>
<!-- Manual Book Selection -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
<p class="text-sm mb-4" style="color: var(--text-secondary)">
Add specific books to this section. Use the search to find and select multiple books.
</p>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
<div class="flex gap-2">
<input type="text" id="book-search" name="book_search"
class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search by title or author..."
autocomplete="off">
<button type="button" id="search-books-btn"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent);">
Search
</button> </button>
</div> </div>
</div> <p class="text-sm mb-4" style="color: var(--text-secondary)">
Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
<div id="search-results" class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto" </p>
style="background-color: var(--bg-primary);"> <div id="rules-container" class="space-y-3"></div>
</div> <div class="mt-4 flex items-center gap-2">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
<div class="mb-4"> <select
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label> id="match-type"
<div id="selected-books" class="min-h-[60px] p-3 rounded-lg border-2 border-dashed" name="match_type"
style="border-color: var(--border); background-color: var(--bg-primary);"> class="px-3 py-1 rounded border"
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
>
<option value="all">ALL rules (AND)</option>
<option value="any">ANY rule (OR)</option>
</select>
</div> </div>
</div> </div>
</div> <!-- Manual Book Selection -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<!-- Live Preview --> <h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);"> <p class="text-sm mb-4" style="color: var(--text-secondary)">
<div class="flex items-center justify-between mb-4"> Add specific books to this section. Use the search to find and select multiple books.
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2> </p>
<button type="button" id="preview-btn" <div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
<div class="flex gap-2">
<input
type="text"
id="book-search"
name="book_search"
class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search by title or author..."
autocomplete="off"
/>
<button
type="button"
id="search-books-btn"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent);"
>
Search
</button>
</div>
</div>
<div
id="search-results"
class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
style="background-color: var(--bg-primary);"
></div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
<div
id="selected-books"
class="min-h-[60px] p-3 rounded-lg border-2 border-dashed"
style="border-color: var(--border); background-color: var(--bg-primary);"
>
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
</div>
</div>
</div>
<!-- Live Preview -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
<button
type="button"
id="preview-btn"
class="px-4 py-2 rounded-lg font-medium" class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent);"> style="background-color: var(--accent);"
Refresh Preview >
Refresh Preview
</button>
</div>
<div
id="preview-container"
class="p-4 rounded-lg"
style="background-color: var(--bg-primary); min-height: 200px;"
>
<p class="text-center" style="color: var(--text-secondary);">
Add filter rules or select books to see a preview of your custom section.
</p>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-end gap-3">
<button
type="button"
id="cancel-btn"
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
Cancel
</button>
<button
type="submit"
id="save-section-btn"
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
style="background-color: var(--accent);"
>
Save Section
</button> </button>
</div> </div>
</form>
<div id="preview-container" class="p-4 rounded-lg" </main>
style="background-color: var(--bg-primary); min-height: 200px;"> @ErrorToast(errorMessage)
<p class="text-center" style="color: var(--text-secondary);"> </body>
Add filter rules or select books to see a preview of your custom section.
</p>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-end gap-3">
<button type="button" id="cancel-btn"
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);">
Cancel
</button>
<button type="submit" id="save-section-btn"
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
style="background-color: var(--accent);">
Save Section
</button>
</div>
</form>
</main>
@ErrorToast(errorMessage)
</body>
</html> </html>
} }
+1 -4
View File
@@ -13,10 +13,7 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Dashboard - Bookhoard</title> <title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script> <script src="/static/main.js"></script>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dashboard.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }">
+227 -213
View File
@@ -1,26 +1,22 @@
package templates package templates
import ( import "fmt"
"fmt"
)
templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) { templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ doc.Title } - Bookhoard Documentation</title> <title>{ doc.Title } - Bookhoard Documentation</title>
<link href="/static/style.css" rel="stylesheet"> <link href="/static/style.css" rel="stylesheet"/>
<link rel="stylesheet" href="/static/highlight-dark.min.css"> <link rel="stylesheet" href="/static/highlight-dark.min.css"/>
<script src="/static/highlight.min.js"></script> <script src="/static/main.js"></script>
<script src="/static/lunr.min.js"></script>
<script src="/static/lunr-flex.min.js"></script>
</head> </head>
<body class={ "theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased" }> <body class={ "theme-" + user.Theme + " page-docs bg-background-primary text-text-primary font-sans antialiased" }>
@Header(user, currentPath) @Header(user, currentPath)
<!-- Mobile Menu Button --> <!-- Mobile Menu Button -->
<button <button
class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80" class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80"
onclick="toggleSidebar()" onclick="toggleSidebar()"
aria-label="Toggle menu" aria-label="Toggle menu"
@@ -29,27 +25,23 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg> </svg>
</button> </button>
<!-- Search Results Overlay --> <!-- Search Results Overlay -->
<div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out"></div> <div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out"></div>
<!-- Sidebar --> <!-- Sidebar -->
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out"> <div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto mt-16 lg:translate-x-0 transition-transform duration-300 ease-in-out">
<!-- Search --> <!-- Search -->
<div class="p-4 border-b border-border"> <div class="p-4 border-b border-border">
<input <input
type="text" type="text"
class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm" class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm"
placeholder="Search documentation..." placeholder="Search documentation..."
id="docs-search" id="docs-search"
/>
>
</div> </div>
<!-- Navigation Sections --> <!-- Navigation Sections -->
for _, section := range nav.Sections { for _, section := range nav.Sections {
<div class="nav-section mb-6"> <div class="nav-section mb-6">
<div <div
class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between" class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between"
onclick="toggleSection(this)" onclick="toggleSection(this)"
> >
@@ -78,7 +70,6 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
</div> </div>
} }
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto"> <main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
<!-- Breadcrumb --> <!-- Breadcrumb -->
@@ -92,10 +83,8 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
} }
</nav> </nav>
} }
<!-- Title --> <!-- Title -->
<h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1> <h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1>
<!-- Table of Contents --> <!-- Table of Contents -->
if len(doc.TOC) > 0 { if len(doc.TOC) > 0 {
<details class="toc bg-background-secondary p-4 rounded-lg mb-8 border border-border"> <details class="toc bg-background-secondary p-4 rounded-lg mb-8 border border-border">
@@ -104,8 +93,8 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<span class="ml-2 text-text-secondary text-xs"></span> <span class="ml-2 text-text-secondary text-xs"></span>
</summary> </summary>
for _, item := range doc.TOC { for _, item := range doc.TOC {
<a <a
href={ "#" + item.Anchor } href={ "#" + item.Anchor }
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline" class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) } style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
> >
@@ -114,155 +103,161 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
} }
</details> </details>
} }
<!-- Content --> <!-- Content -->
<div class="prose prose-invert max-w-none"> <div class="prose prose-invert max-w-none">
@UnsafeHTML(doc.Content).ToComponent() @UnsafeHTML(doc.Content).ToComponent()
</div> </div>
</main> </main>
<script> <script>
// Toggle sidebar section // Toggle sidebar section
function toggleSection(el) { function toggleSection(el) {
const items = el.nextElementSibling; const items = el.nextElementSibling;
const arrow = el.querySelector('.section-arrow'); const arrow = el.querySelector(".section-arrow");
const isCollapsed = items.getAttribute('data-collapsed') === 'true'; const isCollapsed = items.getAttribute("data-collapsed") === "true";
if (isCollapsed) { if (isCollapsed) {
items.classList.remove('hidden'); items.classList.remove("hidden");
items.setAttribute('data-collapsed', 'false'); items.setAttribute("data-collapsed", "false");
arrow.style.transform = 'rotate(0deg)'; arrow.style.transform = "rotate(0deg)";
} else { } else {
items.classList.add('hidden'); items.classList.add("hidden");
items.setAttribute('data-collapsed', 'true'); items.setAttribute("data-collapsed", "true");
arrow.style.transform = 'rotate(-90deg)'; arrow.style.transform = "rotate(-90deg)";
} }
} }
// Toggle sidebar on mobile // Toggle sidebar on mobile
function toggleSidebar() { function toggleSidebar() {
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector(".sidebar");
sidebar.classList.toggle('open'); sidebar.classList.toggle("open");
} }
// Highlight current page in nav // Highlight current page in nav
document.addEventListener('DOMContentLoaded', function() { document.addEventListener("DOMContentLoaded", function () {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
document.querySelectorAll('.nav-item').forEach(item => { document.querySelectorAll(".nav-item").forEach((item) => {
if (item.getAttribute('href') === currentPath) { if (item.getAttribute("href") === currentPath) {
item.classList.add('text-accent', 'bg-accent/10', 'border-r-2', 'border-accent'); item.classList.add(
"text-accent",
"bg-accent/10",
"border-r-2",
"border-accent",
);
} }
}); });
// Initialize syntax highlighting // Initialize syntax highlighting
if (typeof hljs !== 'undefined') { if (typeof hljs !== "undefined") {
hljs.highlightAll(); hljs.highlightAll();
// Add copy buttons to all code blocks // Add copy buttons to all code blocks
document.querySelectorAll('pre code').forEach((block) => { document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement; const pre = block.parentElement;
const wrapper = document.createElement('div'); const wrapper = document.createElement("div");
wrapper.className = 'relative group'; wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre); pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre); wrapper.appendChild(pre);
const button = document.createElement('button'); const button = document.createElement("button");
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent'; button.className =
button.textContent = 'Copy'; "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
button.onclick = async () => { button.textContent = "Copy";
await navigator.clipboard.writeText(block.textContent); button.onclick = async () => {
button.textContent = 'Copied!'; await navigator.clipboard.writeText(block.textContent);
setTimeout(() => { button.textContent = "Copied!";
button.textContent = 'Copy'; setTimeout(() => {
}, 2000); button.textContent = "Copy";
}; }, 2000);
wrapper.appendChild(button); };
}); wrapper.appendChild(button);
});
} }
// Add copy buttons to all code blocks // Add copy buttons to all code blocks
document.querySelectorAll('pre code').forEach((block) => { document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement; const pre = block.parentElement;
const wrapper = document.createElement('div'); const wrapper = document.createElement("div");
wrapper.className = 'relative group'; wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre); pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre); wrapper.appendChild(pre);
const button = document.createElement('button'); const button = document.createElement("button");
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent'; button.className =
button.textContent = 'Copy'; "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
button.textContent = "Copy";
button.onclick = async () => { button.onclick = async () => {
await navigator.clipboard.writeText(block.textContent); await navigator.clipboard.writeText(block.textContent);
button.textContent = 'Copied!'; button.textContent = "Copied!";
setTimeout(() => { setTimeout(() => {
button.textContent = 'Copy'; button.textContent = "Copy";
}, 2000); }, 2000);
}; };
wrapper.appendChild(button); wrapper.appendChild(button);
}); });
}); });
// Add copy buttons to all code blocks // Add copy buttons to all code blocks
document.querySelectorAll('pre code').forEach((block) => { document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement; const pre = block.parentElement;
const wrapper = document.createElement('div'); const wrapper = document.createElement("div");
wrapper.className = 'relative group'; wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre); pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre); wrapper.appendChild(pre);
const button = document.createElement('button'); const button = document.createElement("button");
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent'; button.className =
button.textContent = 'Copy'; "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
button.onclick = async () => { button.textContent = "Copy";
await navigator.clipboard.writeText(block.textContent); button.onclick = async () => {
button.textContent = 'Copied!'; await navigator.clipboard.writeText(block.textContent);
setTimeout(() => { button.textContent = "Copied!";
button.textContent = 'Copy'; setTimeout(() => {
}, 2000); button.textContent = "Copy";
}; }, 2000);
wrapper.appendChild(button); };
}); wrapper.appendChild(button);
});
// Search state // Search state
let idx; let idx;
let searchDocs = []; let searchDocs = [];
// Load index on page load // Load index on page load
fetch('/docs/search-index.json') fetch("/docs/search-index.json")
.then(r => r.json()) .then((r) => r.json())
.then(data => { .then((data) => {
idx = lunr(function() { idx = lunr(function () {
this.use(lunr.flex); this.use(lunr.flex);
this.ref('id'); this.ref("id");
this.field('title', {boost: 10}); this.field("title", { boost: 10 });
this.field('content', {boost: 1}); this.field("content", { boost: 1 });
data.forEach(doc => this.add(doc)); data.forEach((doc) => this.add(doc));
}); });
searchDocs = data; searchDocs = data;
console.log('Search index loaded:', data.length, 'documents'); console.log("Search index loaded:", data.length, "documents");
}) })
.catch(err => console.error('Failed to load search index:', err)); .catch((err) => console.error("Failed to load search index:", err));
// Search input with debounce // Search input with debounce
const searchInput = document.getElementById('docs-search'); const searchInput = document.getElementById("docs-search");
const searchResultsDiv = document.getElementById('search-results'); const searchResultsDiv = document.getElementById("search-results");
let debounceTimer; let debounceTimer;
searchInput.addEventListener('input', (e) => { searchInput.addEventListener("input", (e) => {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
const query = e.target.value.trim(); const query = e.target.value.trim();
if (query.length < 2) { if (query.length < 2) {
searchResultsDiv.classList.add('hidden'); searchResultsDiv.classList.add("hidden");
return; return;
} }
// Search with fuzzy matching // Search with fuzzy matching
const results = idx.search(query, { const results = idx.search(query, {
fields: {title: {boost: 10}, content: 1}, fields: { title: { boost: 10 }, content: 1 },
expand: true expand: true,
}); });
// Render results // Render results
@@ -273,26 +268,29 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
// Render search results // Render search results
function renderSearchResults(results, query) { function renderSearchResults(results, query) {
if (results.length === 0) { if (results.length === 0) {
searchResultsDiv.innerHTML = '<div class="text-center py-8 text-text-secondary">No results found</div>'; searchResultsDiv.innerHTML =
searchResultsDiv.classList.remove('hidden'); '<div class="text-center py-8 text-text-secondary">No results found</div>';
searchResultsDiv.classList.remove("hidden");
return; return;
} }
searchResultsDiv.innerHTML = results.map(r => { searchResultsDiv.innerHTML = results
const doc = searchDocs.find(d => d.id === r.ref); .map((r) => {
if (!doc) return ''; const doc = searchDocs.find((d) => d.id === r.ref);
if (!doc) return "";
const snippet = getSnippet(doc.content, query); const snippet = getSnippet(doc.content, query);
return ` return `
<a href="${doc.url}" class="block p-4 border-b border-border hover:bg-background-secondary text-text-primary no-underline"> <a href="${doc.url}" class="block p-4 border-b border-border hover:bg-background-secondary text-text-primary no-underline">
<div class="font-semibold mb-2">${doc.title}</div> <div class="font-semibold mb-2">${doc.title}</div>
<div class="text-sm text-text-secondary">${snippet}...</div> <div class="text-sm text-text-secondary">${snippet}...</div>
</a> </a>
`; `;
}).join(''); })
.join("");
searchResultsDiv.classList.remove('hidden'); searchResultsDiv.classList.remove("hidden");
} }
function getSnippet(content, query) { function getSnippet(content, query) {
@@ -304,23 +302,26 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
if (idx !== -1) { if (idx !== -1) {
const start = Math.max(0, idx - 50); const start = Math.max(0, idx - 50);
const end = Math.min(content.length, idx + 100); const end = Math.min(content.length, idx + 100);
return '...' + content.substring(start, end) + '...'; return "..." + content.substring(start, end) + "...";
} }
} }
return content.substring(0, 150) + '...'; return content.substring(0, 150) + "...";
} }
// Close search results when clicking outside // Close search results when clicking outside
document.addEventListener('click', (e) => { document.addEventListener("click", (e) => {
if (!searchResultsDiv.contains(e.target) && e.target !== searchInput) { if (
searchResultsDiv.classList.add('hidden'); !searchResultsDiv.contains(e.target) &&
e.target !== searchInput
) {
searchResultsDiv.classList.add("hidden");
} }
}); });
// Close search on Escape key // Close search on Escape key
document.addEventListener('keydown', (e) => { document.addEventListener("keydown", (e) => {
if (e.key === 'Escape') { if (e.key === "Escape") {
searchResultsDiv.classList.add('hidden'); searchResultsDiv.classList.add("hidden");
searchInput.blur(); searchInput.blur();
} }
}); });
@@ -328,129 +329,136 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<script> <script>
// Toggle sidebar on mobile // Toggle sidebar on mobile
function toggleSidebar() { function toggleSidebar() {
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector(".sidebar");
sidebar.classList.toggle('open'); sidebar.classList.toggle("open");
} }
// Highlight current page in nav // Highlight current page in nav
document.addEventListener('DOMContentLoaded', function() { document.addEventListener("DOMContentLoaded", function () {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
document.querySelectorAll('.nav-item').forEach(item => { document.querySelectorAll(".nav-item").forEach((item) => {
if (item.getAttribute('href') === currentPath) { if (item.getAttribute("href") === currentPath) {
item.classList.add('text-accent', 'bg-accent/10', 'border-r-2', 'border-accent'); item.classList.add(
"text-accent",
"bg-accent/10",
"border-r-2",
"border-accent",
);
} }
}); });
// Initialize syntax highlighting // Initialize syntax highlighting
if (typeof hljs !== 'undefined') { if (typeof hljs !== "undefined") {
hljs.highlightAll(); hljs.highlightAll();
// Add copy buttons to all code blocks // Add copy buttons to all code blocks
document.querySelectorAll('pre code').forEach((block) => { document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement; const pre = block.parentElement;
const wrapper = document.createElement('div'); const wrapper = document.createElement("div");
wrapper.className = 'relative group'; wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre); pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre); wrapper.appendChild(pre);
const button = document.createElement('button'); const button = document.createElement("button");
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent'; button.className =
button.textContent = 'Copy'; "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
button.onclick = async () => { button.textContent = "Copy";
await navigator.clipboard.writeText(block.textContent); button.onclick = async () => {
button.textContent = 'Copied!'; await navigator.clipboard.writeText(block.textContent);
setTimeout(() => { button.textContent = "Copied!";
button.textContent = 'Copy'; setTimeout(() => {
}, 2000); button.textContent = "Copy";
}; }, 2000);
wrapper.appendChild(button); };
}); wrapper.appendChild(button);
});
} }
// Add copy buttons to all code blocks // Add copy buttons to all code blocks
document.querySelectorAll('pre code').forEach((block) => { document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement; const pre = block.parentElement;
const wrapper = document.createElement('div'); const wrapper = document.createElement("div");
wrapper.className = 'relative group'; wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre); pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre); wrapper.appendChild(pre);
const button = document.createElement('button'); const button = document.createElement("button");
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent'; button.className =
button.textContent = 'Copy'; "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
button.textContent = "Copy";
button.onclick = async () => { button.onclick = async () => {
await navigator.clipboard.writeText(block.textContent); await navigator.clipboard.writeText(block.textContent);
button.textContent = 'Copied!'; button.textContent = "Copied!";
setTimeout(() => { setTimeout(() => {
button.textContent = 'Copy'; button.textContent = "Copy";
}, 2000); }, 2000);
}; };
wrapper.appendChild(button); wrapper.appendChild(button);
}); });
}); });
// Add copy buttons to all code blocks // Add copy buttons to all code blocks
document.querySelectorAll('pre code').forEach((block) => { document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement; const pre = block.parentElement;
const wrapper = document.createElement('div'); const wrapper = document.createElement("div");
wrapper.className = 'relative group'; wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre); pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre); wrapper.appendChild(pre);
const button = document.createElement('button'); const button = document.createElement("button");
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent'; button.className =
button.textContent = 'Copy'; "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
button.onclick = async () => { button.textContent = "Copy";
await navigator.clipboard.writeText(block.textContent); button.onclick = async () => {
button.textContent = 'Copied!'; await navigator.clipboard.writeText(block.textContent);
setTimeout(() => { button.textContent = "Copied!";
button.textContent = 'Copy'; setTimeout(() => {
}, 2000); button.textContent = "Copy";
}; }, 2000);
wrapper.appendChild(button); };
}); wrapper.appendChild(button);
});
// Search state // Search state
let idx; let idx;
let searchDocs = []; let searchDocs = [];
// Load index on page load // Load index on page load
fetch('/docs/search-index.json') fetch("/docs/search-index.json")
.then(r => r.json()) .then((r) => r.json())
.then(data => { .then((data) => {
idx = lunr(function() { idx = lunr(function () {
this.use(lunr.flex); this.use(lunr.flex);
this.ref('id'); this.ref("id");
this.field('title', {boost: 10}); this.field("title", { boost: 10 });
this.field('content', {boost: 1}); this.field("content", { boost: 1 });
data.forEach(doc => this.add(doc)); data.forEach((doc) => this.add(doc));
}); });
searchDocs = data; searchDocs = data;
console.log('Search index loaded:', data.length, 'documents'); console.log("Search index loaded:", data.length, "documents");
}) })
.catch(err => console.error('Failed to load search index:', err)); .catch((err) => console.error("Failed to load search index:", err));
// Search input with debounce // Search input with debounce
const searchInput = document.getElementById('docs-search'); const searchInput = document.getElementById("docs-search");
const searchResultsDiv = document.getElementById('search-results'); const searchResultsDiv = document.getElementById("search-results");
let debounceTimer; let debounceTimer;
searchInput.addEventListener('input', (e) => { searchInput.addEventListener("input", (e) => {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
const query = e.target.value.trim(); const query = e.target.value.trim();
if (query.length < 2) { if (query.length < 2) {
searchResultsDiv.classList.add('hidden'); searchResultsDiv.classList.add("hidden");
return; return;
} }
// Search with fuzzy matching // Search with fuzzy matching
const results = idx.search(query, { const results = idx.search(query, {
fields: {title: {boost: 10}, content: 1}, fields: { title: { boost: 10 }, content: 1 },
expand: true expand: true,
}); });
// Render results // Render results
@@ -461,26 +469,29 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
// Render search results // Render search results
function renderSearchResults(results, query) { function renderSearchResults(results, query) {
if (results.length === 0) { if (results.length === 0) {
searchResultsDiv.innerHTML = '<div class="text-center py-8 text-text-secondary">No results found</div>'; searchResultsDiv.innerHTML =
searchResultsDiv.classList.remove('hidden'); '<div class="text-center py-8 text-text-secondary">No results found</div>';
searchResultsDiv.classList.remove("hidden");
return; return;
} }
searchResultsDiv.innerHTML = results.map(r => { searchResultsDiv.innerHTML = results
const doc = searchDocs.find(d => d.id === r.ref); .map((r) => {
if (!doc) return ''; const doc = searchDocs.find((d) => d.id === r.ref);
if (!doc) return "";
const snippet = getSnippet(doc.content, query); const snippet = getSnippet(doc.content, query);
return ` return `
<a href="${doc.url}" class="block p-4 border-b border-border hover:bg-background-secondary text-text-primary no-underline"> <a href="${doc.url}" class="block p-4 border-b border-border hover:bg-background-secondary text-text-primary no-underline">
<div class="font-semibold mb-2">${doc.title}</div> <div class="font-semibold mb-2">${doc.title}</div>
<div class="text-sm text-text-secondary">${snippet}...</div> <div class="text-sm text-text-secondary">${snippet}...</div>
</a> </a>
`; `;
}).join(''); })
.join("");
searchResultsDiv.classList.remove('hidden'); searchResultsDiv.classList.remove("hidden");
} }
function getSnippet(content, query) { function getSnippet(content, query) {
@@ -492,23 +503,26 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
if (idx !== -1) { if (idx !== -1) {
const start = Math.max(0, idx - 50); const start = Math.max(0, idx - 50);
const end = Math.min(content.length, idx + 100); const end = Math.min(content.length, idx + 100);
return '...' + content.substring(start, end) + '...'; return "..." + content.substring(start, end) + "...";
} }
} }
return content.substring(0, 150) + '...'; return content.substring(0, 150) + "...";
} }
// Close search results when clicking outside // Close search results when clicking outside
document.addEventListener('click', (e) => { document.addEventListener("click", (e) => {
if (!searchResultsDiv.contains(e.target) && e.target !== searchInput) { if (
searchResultsDiv.classList.add('hidden'); !searchResultsDiv.contains(e.target) &&
e.target !== searchInput
) {
searchResultsDiv.classList.add("hidden");
} }
}); });
// Close search on Escape key // Close search on Escape key
document.addEventListener('keydown', (e) => { document.addEventListener("keydown", (e) => {
if (e.key === 'Escape') { if (e.key === "Escape") {
searchResultsDiv.classList.add('hidden'); searchResultsDiv.classList.add("hidden");
searchInput.blur(); searchInput.blur();
} }
}); });
+15 -3
View File
@@ -1,3 +1,6 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
function getAuthHeader(): string { function getAuthHeader(): string {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
return token ? `Bearer ${token}` : ""; return token ? `Bearer ${token}` : "";
@@ -82,9 +85,7 @@ function handleError(error: unknown, context: string): void {
console.error(`${context}:`, error); console.error(`${context}:`, error);
const message = const message =
error instanceof Error ? error.message : "An unexpected error occurred"; error instanceof Error ? error.message : "An unexpected error occurred";
if ((window as any).showToast?.error) { showToast(message, "error");
(window as any).showToast.error(message);
}
} }
export { export {
@@ -97,3 +98,14 @@ export {
handleVoidResponse, handleVoidResponse,
handleError, handleError,
}; };
Alpine.global("api", {
get: apiGet,
post: apiPost,
put: apiPut,
delete: apiDelete,
patch: apiPatch,
handleResponse: handleResponse,
handleVoidResponse: handleVoidResponse,
handleError: handleError,
});
+13 -1
View File
@@ -1,3 +1,5 @@
import { Alpine } from "./alpine";
function toggleSidebar(): void { function toggleSidebar(): void {
const sidebar = document.getElementById("docs-sidebar"); const sidebar = document.getElementById("docs-sidebar");
const overlay = document.getElementById("docs-overlay"); const overlay = document.getElementById("docs-overlay");
@@ -91,4 +93,14 @@ document.addEventListener("DOMContentLoaded", () => {
initializeDocsSearch(); initializeDocsSearch();
}); });
(window as any).toggleSidebar = toggleSidebar; Alpine.global("docs", {
toggleSidebar,
initializeSearch: initializeDocsSearch,
});
export { toggleSidebar, initializeDocsSearch };
Alpine.global("docs", {
toggleSidebar,
initializeSearch: initializeDocsSearch,
});
+7 -1
View File
@@ -1,6 +1,8 @@
// Password validation for registration form // Password validation for registration form
// Procedural style - no OOP, passive event listeners for password manager compatibility // Procedural style - no OOP, passive event listeners for password manager compatibility
import { Alpine } from "./alpine";
// Validation check functions // Validation check functions
function hasMinimumLength(password: string): boolean { function hasMinimumLength(password: string): boolean {
return password.length >= 8; return password.length >= 8;
@@ -185,4 +187,8 @@ function initPasswordValidation(): void {
} }
// Export for use in template // Export for use in template
(window as any).initPasswordValidation = initPasswordValidation; export { initPasswordValidation };
Alpine.global("validation", {
initPasswordValidation,
});