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
+3 -13
View File
@@ -4,29 +4,23 @@ 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>
<link href="/static/style.css" rel="stylesheet">
</head> </head>
<body class="theme-{ currentUser.Theme }"> <body class="theme-{ currentUser.Theme }">
@Header(currentUser, "/admin/users") @Header(currentUser, "/admin/users")
<!-- Modal Container (populated by HTMX) --> <!-- Modal Container (populated by HTMX) -->
<div id="modal-container"></div> <div id="modal-container"></div>
<div class="flex min-h-screen" style="background-color: var(--bg-primary)"> <div class="flex min-h-screen" style="background-color: var(--bg-primary)">
@AdminSidebar(currentUser, "/admin/users") @AdminSidebar(currentUser, "/admin/users")
<main class="flex-1 p-8"> <main class="flex-1 p-8">
<div class="w-full"> <div class="w-full">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1> <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">User Management</h1>
<p style="color: var(--text-secondary)">Manage user accounts and permissions</p> <p style="color: var(--text-secondary)">Manage user accounts and permissions</p>
</div> </div>
<!-- Users Table --> <!-- Users Table -->
<div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)"> <div class="card rounded-lg border overflow-hidden" style="background-color: var(--bg-secondary); border-color: var(--border)">
<table class="w-full"> <table class="w-full">
@@ -53,12 +47,10 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
</div> </div>
</div> </div>
</td> </td>
<!-- Email --> <!-- Email -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div> <div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
</td> </td>
<!-- Role Toggle (with last-admin protection) --> <!-- Role Toggle (with last-admin protection) -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
if user.Role == "admin" && adminCount == 1 { if user.Role == "admin" && adminCount == 1 {
@@ -93,12 +85,10 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div> <div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
} }
</td> </td>
<!-- Created --> <!-- Created -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm" style="color: var(--text-secondary)">{ user.CreatedAt.Format("2006-01-02") }</div> <div class="text-sm" style="color: var(--text-secondary)">{ user.CreatedAt.Format("2006-01-02") }</div>
</td> </td>
<!-- Actions --> <!-- Actions -->
<td class="px-6 py-4 whitespace-nowrap text-right"> <td class="px-6 py-4 whitespace-nowrap text-right">
<button <button
+86 -59
View File
@@ -4,56 +4,66 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
<!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>
<link href="/static/style.css" rel="stylesheet">
</head> </head>
<body class="theme-{ user.Theme }"> <body class="theme-{ user.Theme }">
@Header(user, "/custom-section") @Header(user, "/custom-section")
<main class="max-w-4xl mx-auto px-4 py-8"> <main class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1> <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> <p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
<form id="custom-section-form" class="space-y-6"> <form id="custom-section-form" class="space-y-6">
<!-- Section Details --> <!-- Section Details -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);"> <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> <h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
<input type="text" id="section-name" name="name" required <input
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500" type="text"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"> id="section-name"
</div> name="name"
required
<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" 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);"
placeholder="📚"> />
</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>
<div class="mt-4"> <div class="mt-4">
<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)">Description</label>
<textarea id="section-description" name="description" rows="2" <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" 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> style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
></textarea>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
<select id="section-library" name="library_id" required <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> <option value="">Select a library...</option>
for _, lib := range libraries { for _, lib := range libraries {
<option value={ lib.ID }>{ lib.Name }</option> <option value={ lib.ID }>{ lib.Name }</option>
@@ -61,107 +71,124 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
</select> </select>
</div> </div>
</div> </div>
<!-- Filter Rules --> <!-- Filter Rules -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);"> <div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2> <h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
<button type="button" id="add-rule-btn" <button
type="button"
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 + Add Rule
</button> </button>
</div> </div>
<p class="text-sm mb-4" style="color: var(--text-secondary)"> <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. Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
</p> </p>
<div id="rules-container" class="space-y-3"></div>
<div id="rules-container" class="space-y-3">
</div>
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 flex items-center gap-2">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label> <label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
<select id="match-type" name="match_type" <select
id="match-type"
name="match_type"
class="px-3 py-1 rounded border" class="px-3 py-1 rounded border"
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="all">ALL rules (AND)</option> <option value="all">ALL rules (AND)</option>
<option value="any">ANY rule (OR)</option> <option value="any">ANY rule (OR)</option>
</select> </select>
</div> </div>
</div> </div>
<!-- Manual Book Selection --> <!-- Manual Book Selection -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);"> <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> <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)"> <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. Add specific books to this section. Use the search to find and select multiple books.
</p> </p>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label> <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input type="text" id="book-search" name="book_search" <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" 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);" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search by title or author..." placeholder="Search by title or author..."
autocomplete="off"> autocomplete="off"
<button type="button" id="search-books-btn" />
<button
type="button"
id="search-books-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);"
>
Search Search
</button> </button>
</div> </div>
</div> </div>
<div
<div id="search-results" class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto" id="search-results"
style="background-color: var(--bg-primary);"> class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
</div> style="background-color: var(--bg-primary);"
></div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label> <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" <div
style="border-color: var(--border); background-color: var(--bg-primary);"> 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> <p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Live Preview --> <!-- Live Preview -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);"> <div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2> <h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
<button type="button" id="preview-btn" <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> </button>
</div> </div>
<div
<div id="preview-container" class="p-4 rounded-lg" id="preview-container"
style="background-color: var(--bg-primary); min-height: 200px;"> class="p-4 rounded-lg"
style="background-color: var(--bg-primary); min-height: 200px;"
>
<p class="text-center" style="color: var(--text-secondary);"> <p class="text-center" style="color: var(--text-secondary);">
Add filter rules or select books to see a preview of your custom section. Add filter rules or select books to see a preview of your custom section.
</p> </p>
</div> </div>
</div> </div>
<!-- Form Actions --> <!-- Form Actions -->
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button type="button" id="cancel-btn" <button
type="button"
id="cancel-btn"
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80" 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);"> style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
Cancel Cancel
</button> </button>
<button type="submit" id="save-section-btn" <button
type="submit"
id="save-section-btn"
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90" class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
style="background-color: var(--accent);"> style="background-color: var(--accent);"
>
Save Section Save Section
</button> </button>
</div> </div>
</form> </form>
</main> </main>
@ErrorToast(errorMessage) @ErrorToast(errorMessage)
</body> </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 }">
+163 -149
View File
@@ -1,21 +1,17 @@
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)
@@ -29,10 +25,8 @@ 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 -->
@@ -42,10 +36,8 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
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">
@@ -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">
@@ -114,66 +103,70 @@ 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.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);
@@ -181,21 +174,22 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
} }
// 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);
@@ -203,21 +197,22 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
}); });
// 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);
@@ -228,41 +223,41 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
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,14 +268,16 @@ 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);
@@ -290,9 +287,10 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<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,40 +329,45 @@ 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.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);
@@ -369,21 +375,22 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
} }
// 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);
@@ -391,21 +398,22 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
}); });
// 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);
@@ -416,41 +424,41 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
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,14 +469,16 @@ 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);
@@ -478,9 +488,10 @@ templ DocsLayout(nav Navigation, doc Document, user User, currentPath string) {
<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,
});