From 2a91ff9477d6a9cc75db86fe60b8284bb739bdde Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Jan 2026 15:51:38 -0500 Subject: [PATCH] feat: add reusable header component with theme switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add header.templ component with app title, search, theme switcher, user menu - Implement dropdown menus for theme selection and user actions - Add wood theme options (Wood Light, Wood Dark, Wood Mahogany) - Support all existing themes with visual color swatches - Auto-close dropdowns when clicking outside - TypeScript header functionality with proper type safety Features: - Left: App title "📚 Bookmann" linking to /bookshelf - Center: Search box (ready for future search functionality) - Right: Theme switcher button with color dropdown → User icon menu - User menu includes Settings, Admin Panel (if admin), and Logout - Theme persistence to localStorage and server via API --- templates/header.templ | 119 ++++++++++++++++++++++++++++++++++++++ templates/header_templ.go | 63 ++++++++++++++++++++ web/src/header.ts | 114 ++++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 templates/header.templ create mode 100644 templates/header_templ.go create mode 100644 web/src/header.ts diff --git a/templates/header.templ b/templates/header.templ new file mode 100644 index 0000000..597e2f4 --- /dev/null +++ b/templates/header.templ @@ -0,0 +1,119 @@ +package templates + +templ Header(user User, currentPath string) { + +} diff --git a/templates/header_templ.go b/templates/header_templ.go new file mode 100644 index 0000000..afe1acf --- /dev/null +++ b/templates/header_templ.go @@ -0,0 +1,63 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Header(user User, currentPath string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/web/src/header.ts b/web/src/header.ts new file mode 100644 index 0000000..bc3cc7c --- /dev/null +++ b/web/src/header.ts @@ -0,0 +1,114 @@ +// Header functionality + +const toggleThemeDropdown = (): void => { + const dropdown = document.getElementById('theme-dropdown'); + if (dropdown) { + dropdown.classList.toggle('hidden'); + + // Close user menu if open + const userMenu = document.getElementById('user-menu'); + if (userMenu && !dropdown.classList.contains('hidden')) { + userMenu.classList.add('hidden'); + } + } +}; + +const toggleUserMenu = (): void => { + const menu = document.getElementById('user-menu'); + if (menu) { + menu.classList.toggle('hidden'); + + // Close theme dropdown if open + const themeDropdown = document.getElementById('theme-dropdown'); + if (themeDropdown && !menu.classList.contains('hidden')) { + themeDropdown.classList.add('hidden'); + } + } +}; + +const changeThemeTo = (theme: string): void => { + // Apply the theme + if (theme.startsWith('wood-')) { + // Apply wood background theme + document.body.className = `theme-${theme}`; + + // Set wood background style + let woodGradient = ''; + switch (theme) { + case 'wood-light': + woodGradient = 'linear-gradient(135deg, #deb887 0%, #d2a679 50%, #c9975b 100%)'; + break; + case 'wood-dark': + woodGradient = 'linear-gradient(135deg, #8b7355 0%, #6b5344 50%, #5a4636 100%)'; + break; + case 'wood-mahogany': + woodGradient = 'linear-gradient(135deg, #a0522d 0%, #8b4513 50%, #7a3c10 100%)'; + break; + } + + document.body.style.background = woodGradient; + document.body.style.backgroundSize = 'cover'; + document.body.style.backgroundAttachment = 'fixed'; + } else { + // Apply regular theme + document.body.className = `theme-${theme}`; + document.body.style.background = ''; + document.body.style.backgroundSize = ''; + document.body.style.backgroundAttachment = ''; + } + + // Save to localStorage + localStorage.setItem('theme', theme); + + // Save to server if logged in + const token = localStorage.getItem('token'); + if (token) { + fetch('/api/auth/theme', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ theme }) + }).catch(err => console.log('Theme save failed', err)); + } + + // Close dropdown + const dropdown = document.getElementById('theme-dropdown'); + if (dropdown) { + dropdown.classList.add('hidden'); + } +}; + +const logout = (): void => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/'; +}; + +// Close dropdowns when clicking outside +document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const themeDropdown = document.getElementById('theme-dropdown'); + const userMenu = document.getElementById('user-menu'); + const themeButton = target?.closest('button[onclick="toggleThemeDropdown()"]'); + const userButton = target?.closest('button[onclick="toggleUserMenu()"]'); + + if (!themeButton && themeDropdown && !themeDropdown.classList.contains('hidden')) { + if (!themeDropdown.contains(target)) { + themeDropdown.classList.add('hidden'); + } + } + + if (!userButton && userMenu && !userMenu.classList.contains('hidden')) { + if (!userMenu.contains(target)) { + userMenu.classList.add('hidden'); + } + } +}); + +// Make functions available globally +(window as any).toggleThemeDropdown = toggleThemeDropdown; +(window as any).toggleUserMenu = toggleUserMenu; +(window as any).changeThemeTo = changeThemeTo; +(window as any).logout = logout;