The code and data behind xeiaso.net
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: sponsorship panel (#1117)

* chore(claude): add skills for templ / htmx

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: add htmx scaffolding

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: regenerate protobufs

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs(sponsor-panel): add AI generated simple spec for the sponsor panel

Assisted-by: GLM 4.7 via Claude Code (agent teams)
Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: add tailwind dependencies

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(web/htmx): go generate again

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: add sponsor-panel service

This is a simple sponsor panel service implementing the spec in
the docs folder. This is the simplest possible thing that could work
and should be Good Enough:tm:.

Assisted-by: GLM 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: deployment for sponsor-panel

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(sponsor-panel): look up users by username, not by O(n) searching

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(sponsor-panel): properly check sponsorship status with GraphQL

Assisted-by: GLM 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(sponsors-panel): use gorilla/sessions now that things work

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(sponsor-panel): clarify organization fuckery

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(sponsor-panel): 3 instances because fuck everything

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(sponsor-panel): favicon support

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(sponsor-panel): strip out debug logging

Signed-off-by: Xe Iaso <me@xeiaso.net>

* ci(go): run in github actions

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(sponsor-panel): danger red for the important part

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(sponsor-panel/templates): remove dead argument

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(sponsor-panel): make the cookie secure flag configurable

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(sponsor-panel): make tailwind config better

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(sponsor-panel): fix image uploads

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>

authored by

Xe Iaso and committed by
GitHub
8cd83af1 9ca65329

+12071 -161
+524
.claude/skills/templ-components/SKILL.md
··· 1 + --- 2 + name: templ-components 3 + description: Create reusable templ UI components with props, children, and composition patterns. Use when building UI components, creating component libraries, mentions 'button component', 'card component', or 'reusable templ components'. 4 + --- 5 + 6 + # Templ Components 7 + 8 + ## Overview 9 + 10 + Build reusable, type-safe UI components with templ. Components accept strongly-typed props and can be composed together to create complex UIs. 11 + 12 + ## When to Use This Skill 13 + 14 + Use when: 15 + 16 + - Creating reusable UI components 17 + - Building component libraries 18 + - User mentions "button", "card", "form", "modal" components 19 + - Designing component APIs 20 + - Working on design systems 21 + 22 + ## Core Component Patterns 23 + 24 + ### Basic Component 25 + 26 + ```templ 27 + package components 28 + 29 + templ Button(text string) { 30 + <button class="btn"> 31 + { text } 32 + </button> 33 + } 34 + ``` 35 + 36 + ### Component with Multiple Props 37 + 38 + ```templ 39 + templ Button(text string, variant string, disabled bool) { 40 + <button 41 + class={ "btn btn-" + variant } 42 + disabled?={ disabled } 43 + > 44 + { text } 45 + </button> 46 + } 47 + ``` 48 + 49 + Usage: 50 + 51 + ```go 52 + components.Button("Submit", "primary", false) 53 + ``` 54 + 55 + ### Component with Children 56 + 57 + ```templ 58 + templ Card(title string) { 59 + <div class="card"> 60 + <div class="card-header"> 61 + <h3>{ title }</h3> 62 + </div> 63 + <div class="card-body"> 64 + { children... } 65 + </div> 66 + </div> 67 + } 68 + ``` 69 + 70 + Usage: 71 + 72 + ```templ 73 + @Card("User Profile") { 74 + <p>Name: John Doe</p> 75 + <p>Email: john@example.com</p> 76 + } 77 + ``` 78 + 79 + ### Component with Struct Props 80 + 81 + ```templ 82 + package components 83 + 84 + type ButtonProps struct { 85 + Text string 86 + Variant string 87 + Disabled bool 88 + OnClick string 89 + } 90 + 91 + templ Button(props ButtonProps) { 92 + <button 93 + class={ "btn btn-" + props.Variant } 94 + disabled?={ props.Disabled } 95 + onclick={ props.OnClick } 96 + > 97 + { props.Text } 98 + </button> 99 + } 100 + ``` 101 + 102 + ## Common Components 103 + 104 + ### Button Variants 105 + 106 + ```templ 107 + templ PrimaryButton(text string) { 108 + <button class="btn btn-primary">{ text }</button> 109 + } 110 + 111 + templ SecondaryButton(text string) { 112 + <button class="btn btn-secondary">{ text }</button> 113 + } 114 + 115 + templ DangerButton(text string, onclick string) { 116 + <button class="btn btn-danger" onclick={ onclick }> 117 + { text } 118 + </button> 119 + } 120 + ``` 121 + 122 + ### Card Component 123 + 124 + ```templ 125 + templ Card(title string, footer string) { 126 + <div class="card"> 127 + if title != "" { 128 + <div class="card-header"> 129 + <h3>{ title }</h3> 130 + </div> 131 + } 132 + <div class="card-body"> 133 + { children... } 134 + </div> 135 + if footer != "" { 136 + <div class="card-footer"> 137 + { footer } 138 + </div> 139 + } 140 + </div> 141 + } 142 + ``` 143 + 144 + ### List Component 145 + 146 + ```templ 147 + type ListItem struct { 148 + ID string 149 + Text string 150 + } 151 + 152 + templ List(items []ListItem) { 153 + <ul class="list"> 154 + for _, item := range items { 155 + <li data-id={ item.ID }> 156 + { item.Text } 157 + </li> 158 + } 159 + </ul> 160 + } 161 + ``` 162 + 163 + ### Modal Component 164 + 165 + ```templ 166 + templ Modal(id string, title string, isOpen bool) { 167 + <div 168 + class={ "modal", templ.KV("modal-open", isOpen) } 169 + id={ id } 170 + > 171 + <div class="modal-backdrop"></div> 172 + <div class="modal-content"> 173 + <div class="modal-header"> 174 + <h2>{ title }</h2> 175 + <button class="modal-close">&times;</button> 176 + </div> 177 + <div class="modal-body"> 178 + { children... } 179 + </div> 180 + </div> 181 + </div> 182 + } 183 + ``` 184 + 185 + ### Form Components 186 + 187 + ```templ 188 + templ Input(name string, label string, value string) { 189 + <div class="form-group"> 190 + <label for={ name }>{ label }</label> 191 + <input 192 + type="text" 193 + id={ name } 194 + name={ name } 195 + value={ value } 196 + class="form-control" 197 + /> 198 + </div> 199 + } 200 + 201 + templ TextArea(name string, label string, rows int) { 202 + <div class="form-group"> 203 + <label for={ name }>{ label }</label> 204 + <textarea 205 + id={ name } 206 + name={ name } 207 + rows={ strconv.Itoa(rows) } 208 + class="form-control" 209 + > 210 + { children... } 211 + </textarea> 212 + </div> 213 + } 214 + 215 + templ Select(name string, label string, options []string) { 216 + <div class="form-group"> 217 + <label for={ name }>{ label }</label> 218 + <select id={ name } name={ name } class="form-control"> 219 + for _, option := range options { 220 + <option value={ option }>{ option }</option> 221 + } 222 + </select> 223 + </div> 224 + } 225 + ``` 226 + 227 + ## Layout Components 228 + 229 + ### Container 230 + 231 + ```templ 232 + templ Container(fluid bool) { 233 + <div class={ templ.KV("container", !fluid), templ.KV("container-fluid", fluid) }> 234 + { children... } 235 + </div> 236 + } 237 + ``` 238 + 239 + ### Grid 240 + 241 + ```templ 242 + templ Row() { 243 + <div class="row"> 244 + { children... } 245 + </div> 246 + } 247 + 248 + templ Col(size int) { 249 + <div class={ "col-" + strconv.Itoa(size) }> 250 + { children... } 251 + </div> 252 + } 253 + ``` 254 + 255 + Usage: 256 + 257 + ```templ 258 + @Container(false) { 259 + @Row() { 260 + @Col(6) { 261 + <p>Left column</p> 262 + } 263 + @Col(6) { 264 + <p>Right column</p> 265 + } 266 + } 267 + } 268 + ``` 269 + 270 + ### Navigation 271 + 272 + ```templ 273 + type NavItem struct { 274 + Text string 275 + Href string 276 + Active bool 277 + } 278 + 279 + templ Nav(items []NavItem) { 280 + <nav class="navbar"> 281 + <ul class="nav"> 282 + for _, item := range items { 283 + <li class={ templ.KV("nav-item-active", item.Active) }> 284 + <a href={ templ.URL(item.Href) }> 285 + { item.Text } 286 + </a> 287 + </li> 288 + } 289 + </ul> 290 + </nav> 291 + } 292 + ``` 293 + 294 + ## Composition Patterns 295 + 296 + ### Slots Pattern 297 + 298 + ```templ 299 + templ Layout(title string, headerContent templ.Component, footerContent templ.Component) { 300 + <!DOCTYPE html> 301 + <html> 302 + <head> 303 + <title>{ title }</title> 304 + </head> 305 + <body> 306 + <header> 307 + @headerContent 308 + </header> 309 + <main> 310 + { children... } 311 + </main> 312 + <footer> 313 + @footerContent 314 + </footer> 315 + </body> 316 + </html> 317 + } 318 + ``` 319 + 320 + ### Render Props Pattern 321 + 322 + ```templ 323 + templ DataTable(headers []string, renderRow func(int) templ.Component) { 324 + <table> 325 + <thead> 326 + <tr> 327 + for _, header := range headers { 328 + <th>{ header }</th> 329 + } 330 + </tr> 331 + </thead> 332 + <tbody> 333 + for i := 0; i < 10; i++ { 334 + @renderRow(i) 335 + } 336 + </tbody> 337 + </table> 338 + } 339 + ``` 340 + 341 + ### Wrapper Components 342 + 343 + ```templ 344 + templ WithLoading(isLoading bool) { 345 + if isLoading { 346 + <div class="spinner">Loading...</div> 347 + } else { 348 + { children... } 349 + } 350 + } 351 + 352 + templ WithError(err error) { 353 + if err != nil { 354 + <div class="alert alert-error"> 355 + { err.Error() } 356 + </div> 357 + } else { 358 + { children... } 359 + } 360 + } 361 + ``` 362 + 363 + ## Best Practices 364 + 365 + ### 1. Single Responsibility 366 + 367 + ```templ 368 + // ✅ Good: One purpose 369 + templ Avatar(src string, alt string) { 370 + <img src={ src } alt={ alt } class="avatar" /> 371 + } 372 + 373 + // ❌ Bad: Too many responsibilities 374 + templ UserSection(user User, posts []Post, comments []Comment) { 375 + // Too much in one component 376 + } 377 + ``` 378 + 379 + ### 2. Type-Safe Props 380 + 381 + ```templ 382 + // ✅ Good: Strongly typed 383 + type ButtonProps struct { 384 + Text string 385 + Variant ButtonVariant 386 + } 387 + 388 + type ButtonVariant string 389 + 390 + const ( 391 + Primary ButtonVariant = "primary" 392 + Secondary ButtonVariant = "secondary" 393 + ) 394 + 395 + templ Button(props ButtonProps) { 396 + <button class={ "btn btn-" + string(props.Variant) }> 397 + { props.Text } 398 + </button> 399 + } 400 + ``` 401 + 402 + ### 3. Composition Over Complexity 403 + 404 + ```templ 405 + // ✅ Good: Compose small components 406 + templ UserCard(user User) { 407 + @Card(user.Name) { 408 + @Avatar(user.AvatarURL, user.Name) 409 + @UserInfo(user) 410 + @UserActions(user.ID) 411 + } 412 + } 413 + 414 + // Each sub-component is simple and reusable 415 + ``` 416 + 417 + ### 4. Conditional Rendering 418 + 419 + ```templ 420 + // ✅ Good: Clear conditions 421 + templ Message(text string, isError bool) { 422 + if isError { 423 + <div class="alert-error">{ text }</div> 424 + } else { 425 + <div class="alert-info">{ text }</div> 426 + } 427 + } 428 + ``` 429 + 430 + ### 5. Default Props 431 + 432 + ```go 433 + // In Go code 434 + type ButtonProps struct { 435 + Text string 436 + Variant string 437 + Disabled bool 438 + } 439 + 440 + func NewButton(text string) ButtonProps { 441 + return ButtonProps{ 442 + Text: text, 443 + Variant: "primary", 444 + Disabled: false, 445 + } 446 + } 447 + ``` 448 + 449 + ```templ 450 + templ Button(props ButtonProps) { 451 + <button 452 + class={ "btn btn-" + props.Variant } 453 + disabled?={ props.Disabled } 454 + > 455 + { props.Text } 456 + </button> 457 + } 458 + ``` 459 + 460 + ### Package Organization 461 + 462 + ```templ 463 + // components/ui/button.templ 464 + package ui 465 + 466 + templ Button(text string) { 467 + <button class="btn">{ text }</button> 468 + } 469 + ``` 470 + 471 + ```templ 472 + // components/layout/container.templ 473 + package layout 474 + 475 + templ Container() { 476 + <div class="container"> 477 + { children... } 478 + </div> 479 + } 480 + ``` 481 + 482 + Usage: 483 + 484 + ```go 485 + import ( 486 + "myapp/components/ui" 487 + "myapp/components/layout" 488 + ) 489 + 490 + layout.Container() { 491 + ui.Button("Click me") 492 + } 493 + ``` 494 + 495 + ## Testing Components 496 + 497 + ```go 498 + func TestButton(t *testing.T) { 499 + var buf bytes.Buffer 500 + props := ButtonProps{ 501 + Text: "Submit", 502 + Variant: "primary", 503 + } 504 + 505 + err := Button(props).Render(context.Background(), &buf) 506 + if err != nil { 507 + t.Fatal(err) 508 + } 509 + 510 + html := buf.String() 511 + if !strings.Contains(html, "Submit") { 512 + t.Error("Button text not found") 513 + } 514 + if !strings.Contains(html, "btn-primary") { 515 + t.Error("Primary class not found") 516 + } 517 + } 518 + ``` 519 + 520 + ## Next Steps 521 + 522 + - **Connect to server** → Use `templ-http` skill 523 + - **Add interactivity** → Use `templ-htmx` skill 524 + - **Style components** → Use `templ-css` skill
+653
.claude/skills/templ-htmx/SKILL.md
··· 1 + --- 2 + name: templ-htmx 3 + description: Build interactive hypermedia-driven applications with templ and HTMX. Use when creating dynamic UIs, real-time updates, AJAX interactions, mentions 'HTMX', 'dynamic content', or 'interactive templ app'. 4 + --- 5 + 6 + # Templ + HTMX Integration 7 + 8 + ## Overview 9 + 10 + HTMX enables modern, interactive web applications with minimal JavaScript. Combined with templ's type-safe components, you get fast, reliable hypermedia-driven UIs. 11 + 12 + **Key Benefits:** 13 + 14 + - No JavaScript framework needed 15 + - Server-side rendering 16 + - Minimal client-side code 17 + - Progressive enhancement 18 + - Type-safe components 19 + 20 + ## When to Use This Skill 21 + 22 + Use when: 23 + 24 + - Building interactive UIs 25 + - Creating dynamic content 26 + - User mentions "HTMX", "dynamic updates", "real-time" 27 + - Implementing AJAX-like behavior without JS 28 + - Building SPAs without frameworks 29 + 30 + ## Quick Start 31 + 32 + ### 1. Import and Mount HTMX 33 + 34 + First, import the htmx package and mount it in your server: 35 + 36 + ```go 37 + import ( 38 + "xeiaso.net/v4/web/htmx" 39 + ) 40 + 41 + func main() { 42 + mux := http.NewServeMux() 43 + 44 + // Mount HTMX static files at /.within.website/x/htmx/ 45 + htmx.Mount(mux) 46 + 47 + // ... other routes 48 + } 49 + ``` 50 + 51 + ### 2. Add HTMX to Layout 52 + 53 + ```templ 54 + package components 55 + 56 + import "xeiaso.net/v4/web/htmx" 57 + 58 + templ Layout(title string) { 59 + <!DOCTYPE html> 60 + <html> 61 + <head> 62 + <title>{ title }</title> 63 + @htmx.Use() 64 + </head> 65 + <body> 66 + { children... } 67 + </body> 68 + </html> 69 + } 70 + ``` 71 + 72 + The `htmx.Use()` component includes the core HTMX library. You can add extensions: 73 + 74 + ```templ 75 + // Add SSE and path-params extensions 76 + @htmx.Use("sse", "path-params") 77 + ``` 78 + 79 + Available extensions: `event-header`, `path-params`, `remove-me`, `websocket`. 80 + 81 + ### 3. Detect HTMX Requests 82 + 83 + Use the `htmx.Is()` function to check if a request was made by HTMX: 84 + 85 + ```go 86 + import "xeiaso.net/v4/web/htmx" 87 + 88 + func handler(w http.ResponseWriter, r *http.Request) { 89 + if htmx.Is(r) { 90 + // Return partial HTML fragment for HTMX 91 + components.Partial().Render(r.Context(), w) 92 + } else { 93 + // Return full page for direct navigation 94 + components.FullPage().Render(r.Context(), w) 95 + } 96 + } 97 + ``` 98 + 99 + ### 4. Create Interactive Component 100 + 101 + ```templ 102 + templ Counter(count int) { 103 + <div> 104 + <p>Count: { strconv.Itoa(count) }</p> 105 + <button 106 + hx-post="/counter/increment" 107 + hx-target="#counter" 108 + hx-swap="outerHTML" 109 + > 110 + Increment 111 + </button> 112 + </div> 113 + } 114 + ``` 115 + 116 + ### 5. Create Handler 117 + 118 + ```go 119 + func incrementHandler(w http.ResponseWriter, r *http.Request) { 120 + count := getCount() + 1 121 + saveCount(count) 122 + 123 + components.Counter(count).Render(r.Context(), w) 124 + } 125 + ``` 126 + 127 + ## Core HTMX Attributes 128 + 129 + ### hx-get / hx-post 130 + 131 + Trigger HTTP requests: 132 + 133 + ```templ 134 + templ SearchBox() { 135 + <input 136 + type="text" 137 + name="q" 138 + hx-get="/search" 139 + hx-trigger="keyup changed delay:500ms" 140 + hx-target="#results" 141 + /> 142 + <div id="results"></div> 143 + } 144 + ``` 145 + 146 + Handler: 147 + 148 + ```go 149 + func searchHandler(w http.ResponseWriter, r *http.Request) { 150 + query := r.URL.Query().Get("q") 151 + results := search(query) 152 + 153 + components.SearchResults(results).Render(r.Context(), w) 154 + } 155 + ``` 156 + 157 + ### hx-target 158 + 159 + Specify where to insert response: 160 + 161 + ```templ 162 + templ LoadMore(page int) { 163 + <button 164 + hx-get={ "/posts?page=" + strconv.Itoa(page) } 165 + hx-target="#posts" 166 + hx-swap="beforeend" 167 + > 168 + Load More 169 + </button> 170 + } 171 + ``` 172 + 173 + ### hx-swap 174 + 175 + Control how content is swapped: 176 + 177 + ```templ 178 + // innerHTML (default) 179 + hx-swap="innerHTML" 180 + 181 + // outerHTML - replace element itself 182 + hx-swap="outerHTML" 183 + 184 + // beforeend - append inside 185 + hx-swap="beforeend" 186 + 187 + // afterend - insert after 188 + hx-swap="afterend" 189 + ``` 190 + 191 + ### hx-trigger 192 + 193 + Control when requests fire: 194 + 195 + ```templ 196 + // On click (default for buttons) 197 + <button hx-get="/data">Click me</button> 198 + 199 + // On change 200 + <select hx-get="/filter" hx-trigger="change"> 201 + 202 + // On keyup with delay 203 + <input hx-get="/search" hx-trigger="keyup changed delay:300ms"> 204 + 205 + // On page load 206 + <div hx-get="/data" hx-trigger="load"> 207 + 208 + // Every 5 seconds 209 + <div hx-get="/updates" hx-trigger="every 5s"> 210 + ``` 211 + 212 + ## Common Patterns 213 + 214 + ### Pattern 1: Live Search 215 + 216 + Component: 217 + 218 + ```templ 219 + templ SearchBox() { 220 + <div> 221 + <input 222 + type="text" 223 + name="q" 224 + placeholder="Search..." 225 + hx-get="/search" 226 + hx-trigger="keyup changed delay:500ms" 227 + hx-target="#search-results" 228 + hx-indicator="#spinner" 229 + /> 230 + <span id="spinner" class="htmx-indicator"> 231 + Searching... 232 + </span> 233 + </div> 234 + <div id="search-results"></div> 235 + } 236 + 237 + templ SearchResults(results []string) { 238 + <ul> 239 + for _, result := range results { 240 + <li>{ result }</li> 241 + } 242 + </ul> 243 + } 244 + ``` 245 + 246 + Handler: 247 + 248 + ```go 249 + func searchHandler(w http.ResponseWriter, r *http.Request) { 250 + query := r.URL.Query().Get("q") 251 + results := performSearch(query) 252 + 253 + components.SearchResults(results).Render(r.Context(), w) 254 + } 255 + ``` 256 + 257 + ### Pattern 2: Infinite Scroll 258 + 259 + ```templ 260 + templ PostList(posts []Post, page int) { 261 + <div id="posts"> 262 + for _, post := range posts { 263 + @PostCard(post) 264 + } 265 + </div> 266 + 267 + if len(posts) > 0 { 268 + <div 269 + hx-get={ "/posts?page=" + strconv.Itoa(page+1) } 270 + hx-trigger="revealed" 271 + hx-swap="outerHTML" 272 + > 273 + Loading more... 274 + </div> 275 + } 276 + } 277 + ``` 278 + 279 + ### Pattern 3: Delete with Confirmation 280 + 281 + ```templ 282 + templ DeleteButton(itemID string) { 283 + <button 284 + hx-delete={ "/items/" + itemID } 285 + hx-confirm="Are you sure?" 286 + hx-target="closest tr" 287 + hx-swap="outerHTML swap:1s" 288 + > 289 + Delete 290 + </button> 291 + } 292 + ``` 293 + 294 + Handler: 295 + 296 + ```go 297 + func deleteHandler(w http.ResponseWriter, r *http.Request) { 298 + itemID := strings.TrimPrefix(r.URL.Path, "/items/") 299 + deleteItem(itemID) 300 + 301 + // Return empty to remove element 302 + w.WriteHeader(http.StatusOK) 303 + } 304 + ``` 305 + 306 + ### Pattern 4: Inline Edit 307 + 308 + ```templ 309 + templ EditableField(id string, value string) { 310 + <div id={ "field-" + id }> 311 + <span>{ value }</span> 312 + <button 313 + hx-get={ "/edit/" + id } 314 + hx-target={ "#field-" + id } 315 + hx-swap="outerHTML" 316 + > 317 + Edit 318 + </button> 319 + </div> 320 + } 321 + 322 + templ EditForm(id string, value string) { 323 + <form 324 + hx-post={ "/save/" + id } 325 + hx-target={ "#field-" + id } 326 + hx-swap="outerHTML" 327 + > 328 + <input type="text" name="value" value={ value } /> 329 + <button type="submit">Save</button> 330 + <button 331 + hx-get={ "/cancel/" + id } 332 + hx-target={ "#field-" + id } 333 + > 334 + Cancel 335 + </button> 336 + </form> 337 + } 338 + ``` 339 + 340 + ### Pattern 5: Form Validation 341 + 342 + ```templ 343 + templ SignupForm() { 344 + <form hx-post="/signup" hx-target="#form-errors"> 345 + <div id="form-errors"></div> 346 + 347 + <input 348 + type="email" 349 + name="email" 350 + hx-post="/validate/email" 351 + hx-trigger="blur" 352 + hx-target="#email-error" 353 + /> 354 + <div id="email-error"></div> 355 + 356 + <input type="password" name="password" /> 357 + 358 + <button type="submit">Sign Up</button> 359 + </form> 360 + } 361 + 362 + templ ValidationError(message string) { 363 + <span class="error">{ message }</span> 364 + } 365 + ``` 366 + 367 + ### Pattern 6: Polling / Real-time Updates 368 + 369 + ```templ 370 + templ LiveStats() { 371 + <div 372 + hx-get="/stats" 373 + hx-trigger="load, every 5s" 374 + hx-swap="innerHTML" 375 + > 376 + Loading stats... 377 + </div> 378 + } 379 + 380 + templ StatsDisplay(stats Stats) { 381 + <div> 382 + <p>Users online: { strconv.Itoa(stats.UsersOnline) }</p> 383 + <p>Active sessions: { strconv.Itoa(stats.Sessions) }</p> 384 + </div> 385 + } 386 + ``` 387 + 388 + ## Advanced Patterns 389 + 390 + ### Out-of-Band Updates (OOB) 391 + 392 + Update multiple parts of page: 393 + 394 + ```templ 395 + templ CartButton(count int) { 396 + <button id="cart-btn"> 397 + Cart ({ strconv.Itoa(count) }) 398 + </button> 399 + } 400 + 401 + templ AddToCartResponse(item Item) { 402 + // Main response 403 + <div class="notification"> 404 + Added { item.Name } to cart! 405 + </div> 406 + 407 + // Update cart button (different part of page) 408 + <div id="cart-btn" hx-swap-oob="true"> 409 + @CartButton(getCartCount()) 410 + </div> 411 + } 412 + ``` 413 + 414 + ### Progressive Enhancement 415 + 416 + ```templ 417 + templ Form() { 418 + <form 419 + action="/submit" 420 + method="POST" 421 + hx-post="/submit" 422 + hx-target="#result" 423 + > 424 + <input type="text" name="data" /> 425 + <button type="submit">Submit</button> 426 + </form> 427 + <div id="result"></div> 428 + } 429 + ``` 430 + 431 + Works without JavaScript, enhanced with HTMX. 432 + 433 + ### Loading States 434 + 435 + ```templ 436 + templ DataTable() { 437 + <div 438 + hx-get="/data" 439 + hx-trigger="load" 440 + hx-indicator="#loading" 441 + > 442 + <div id="loading" class="htmx-indicator"> 443 + Loading data... 444 + </div> 445 + </div> 446 + } 447 + ``` 448 + 449 + CSS: 450 + 451 + ```css 452 + .htmx-indicator { 453 + display: none; 454 + } 455 + 456 + .htmx-request .htmx-indicator { 457 + display: inline; 458 + } 459 + 460 + .htmx-request.htmx-indicator { 461 + display: inline; 462 + } 463 + ``` 464 + 465 + ## Response Headers 466 + 467 + ### HX-Trigger 468 + 469 + Trigger client-side events: 470 + 471 + ```go 472 + func handler(w http.ResponseWriter, r *http.Request) { 473 + // Do work... 474 + 475 + // Trigger custom event 476 + w.Header().Set("HX-Trigger", "itemCreated") 477 + 478 + components.Success().Render(r.Context(), w) 479 + } 480 + ``` 481 + 482 + Client side: 483 + 484 + ```javascript 485 + document.body.addEventListener("itemCreated", function (evt) { 486 + console.log("Item created!"); 487 + }); 488 + ``` 489 + 490 + ### HX-Redirect 491 + 492 + ```go 493 + func handler(w http.ResponseWriter, r *http.Request) { 494 + w.Header().Set("HX-Redirect", "/dashboard") 495 + w.WriteHeader(http.StatusOK) 496 + } 497 + ``` 498 + 499 + ### HX-Refresh 500 + 501 + ```go 502 + func handler(w http.ResponseWriter, r *http.Request) { 503 + w.Header().Set("HX-Refresh", "true") 504 + w.WriteHeader(http.StatusOK) 505 + } 506 + ``` 507 + 508 + ### Special Status Code 509 + 510 + Stop polling with status code 286: 511 + 512 + ```go 513 + import "within.website/x/htmx" 514 + 515 + func pollHandler(w http.ResponseWriter, r *http.Request) { 516 + if shouldStopPolling() { 517 + w.WriteHeader(htmx.StatusStopPolling) 518 + return 519 + } 520 + // ... return normal content 521 + } 522 + ``` 523 + 524 + ### Request Headers 525 + 526 + Access HTMX request headers: 527 + 528 + ```go 529 + import "within.website/x/htmx" 530 + 531 + func handler(w http.ResponseWriter, r *http.Request) { 532 + // Check if this is an HTMX request 533 + if htmx.Is(r) { 534 + // Get user's response to hx-prompt 535 + promptResponse := r.Header.Get(htmx.HeaderPrompt) 536 + } 537 + } 538 + ``` 539 + 540 + ## Best Practices 541 + 542 + 1. **Keep handlers focused** - Return only the HTML fragment needed 543 + 2. **Use semantic HTML** - Works without JS 544 + 3. **Handle errors gracefully** - Return error components 545 + 4. **Optimize responses** - Send minimal HTML 546 + 5. **Use OOB for multi-updates** - Update multiple page sections 547 + 6. **Progressive enhancement** - Always provide fallback 548 + 549 + ## Full Example: Todo App 550 + 551 + ```templ 552 + // components/todo.templ 553 + package components 554 + 555 + type Todo struct { 556 + ID string 557 + Text string 558 + Completed bool 559 + } 560 + 561 + templ TodoApp(todos []Todo) { 562 + @Layout("Todo App") { 563 + <div> 564 + <h1>My Todos</h1> 565 + 566 + @TodoForm() 567 + @TodoList(todos) 568 + </div> 569 + } 570 + } 571 + 572 + templ TodoForm() { 573 + <form 574 + hx-post="/todos" 575 + hx-target="#todo-list" 576 + hx-swap="beforeend" 577 + hx-on::after-request="this.reset()" 578 + > 579 + <input 580 + type="text" 581 + name="text" 582 + placeholder="New todo..." 583 + required 584 + /> 585 + <button type="submit">Add</button> 586 + </form> 587 + } 588 + 589 + templ TodoList(todos []Todo) { 590 + <ul id="todo-list"> 591 + for _, todo := range todos { 592 + @TodoItem(todo) 593 + } 594 + </ul> 595 + } 596 + 597 + templ TodoItem(todo Todo) { 598 + <li id={ "todo-" + todo.ID }> 599 + <input 600 + type="checkbox" 601 + checked?={ todo.Completed } 602 + hx-post={ "/todos/" + todo.ID + "/toggle" } 603 + hx-target={ "#todo-" + todo.ID } 604 + hx-swap="outerHTML" 605 + /> 606 + <span class={ templ.KV("completed", todo.Completed) }> 607 + { todo.Text } 608 + </span> 609 + <button 610 + hx-delete={ "/todos/" + todo.ID } 611 + hx-target={ "#todo-" + todo.ID } 612 + hx-swap="outerHTML swap:500ms" 613 + > 614 + Delete 615 + </button> 616 + </li> 617 + } 618 + ``` 619 + 620 + Handlers: 621 + 622 + ```go 623 + func todosHandler(w http.ResponseWriter, r *http.Request) { 624 + switch r.Method { 625 + case "GET": 626 + todos := getAllTodos() 627 + components.TodoApp(todos).Render(r.Context(), w) 628 + 629 + case "POST": 630 + r.ParseForm() 631 + todo := createTodo(r.FormValue("text")) 632 + components.TodoItem(todo).Render(r.Context(), w) 633 + } 634 + } 635 + 636 + func todoToggleHandler(w http.ResponseWriter, r *http.Request) { 637 + id := extractID(r.URL.Path) 638 + todo := toggleTodo(id) 639 + components.TodoItem(todo).Render(r.Context(), w) 640 + } 641 + 642 + func todoDeleteHandler(w http.ResponseWriter, r *http.Request) { 643 + id := extractID(r.URL.Path) 644 + deleteTodo(id) 645 + w.WriteHeader(http.StatusOK) // Empty response removes element 646 + } 647 + ``` 648 + 649 + ## Resources 650 + 651 + - [HTMX Documentation](https://htmx.org/docs/) 652 + - [HTMX Examples](https://htmx.org/examples/) 653 + - [Hypermedia Systems Book](https://hypermedia.systems/)
+409
.claude/skills/templ-http/SKILL.md
··· 1 + --- 2 + name: templ-http 3 + description: Integrate templ components with Go HTTP server using net/http. Use when connecting templ to web server, creating HTTP handlers, mentions 'templ server', 'HTTP routes', or 'serve templ components'. 4 + --- 5 + 6 + # Templ HTTP Integration 7 + 8 + ## Overview 9 + 10 + Connect templ components to Go's `net/http` server. Render components in HTTP handlers and serve dynamic HTML pages. 11 + 12 + ## When to Use This Skill 13 + 14 + Use when: 15 + 16 + - Setting up HTTP server with templ 17 + - Creating route handlers 18 + - User mentions "serve templ", "HTTP server", "web server" 19 + - Connecting components to routes 20 + - Rendering templ in handlers 21 + 22 + ## Basic Integration 23 + 24 + ### Simple Handler 25 + 26 + ```go 27 + package main 28 + 29 + import ( 30 + "net/http" 31 + "myapp/components" 32 + ) 33 + 34 + func homeHandler(w http.ResponseWriter, r *http.Request) { 35 + components.HomePage().Render(r.Context(), w) 36 + } 37 + 38 + func main() { 39 + http.HandleFunc("/", homeHandler) 40 + http.ListenAndServe(":8080", nil) 41 + } 42 + ``` 43 + 44 + ### Handler with Data 45 + 46 + ```go 47 + func userHandler(w http.ResponseWriter, r *http.Request) { 48 + user := getUserFromDB(r.URL.Query().Get("id")) 49 + 50 + components.UserProfile(user).Render(r.Context(), w) 51 + } 52 + ``` 53 + 54 + ## Rendering Patterns 55 + 56 + ### Pattern 1: Direct Render 57 + 58 + ```go 59 + func handler(w http.ResponseWriter, r *http.Request) { 60 + component := components.Page("Title") 61 + component.Render(r.Context(), w) 62 + } 63 + ``` 64 + 65 + ### Pattern 2: Error Handling 66 + 67 + ```go 68 + func handler(w http.ResponseWriter, r *http.Request) { 69 + err := components.Page("Title").Render(r.Context(), w) 70 + if err != nil { 71 + http.Error(w, "Render failed", http.StatusInternalServerError) 72 + log.Printf("Render error: %v", err) 73 + } 74 + } 75 + ``` 76 + 77 + ### Pattern 3: With Layout 78 + 79 + ```go 80 + func pageHandler(w http.ResponseWriter, r *http.Request) { 81 + content := components.PageContent() 82 + 83 + components.Layout("Page Title", content).Render(r.Context(), w) 84 + } 85 + ``` 86 + 87 + ## Routing 88 + 89 + ### ServeMux 90 + 91 + ```go 92 + func main() { 93 + mux := http.NewServeMux() 94 + 95 + // Static pages 96 + mux.HandleFunc("/", homeHandler) 97 + mux.HandleFunc("/about", aboutHandler) 98 + 99 + // Dynamic routes 100 + mux.HandleFunc("/user/", userHandler) 101 + mux.HandleFunc("/post/", postHandler) 102 + 103 + // Static files 104 + fs := http.FileServer(http.Dir("static")) 105 + mux.Handle("/static/", http.StripPrefix("/static/", fs)) 106 + 107 + http.ListenAndServe(":8080", mux) 108 + } 109 + ``` 110 + 111 + ### RESTful Routes 112 + 113 + ```go 114 + func usersHandler(w http.ResponseWriter, r *http.Request) { 115 + switch r.Method { 116 + case "GET": 117 + users := getAllUsers() 118 + components.UserList(users).Render(r.Context(), w) 119 + 120 + case "POST": 121 + // Handle create 122 + user := createUser(r) 123 + components.UserCard(user).Render(r.Context(), w) 124 + 125 + default: 126 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 127 + } 128 + } 129 + ``` 130 + 131 + ## Request Data 132 + 133 + ### Query Parameters 134 + 135 + ```go 136 + func searchHandler(w http.ResponseWriter, r *http.Request) { 137 + query := r.URL.Query().Get("q") 138 + page := r.URL.Query().Get("page") 139 + 140 + results := search(query, page) 141 + 142 + components.SearchResults(query, results).Render(r.Context(), w) 143 + } 144 + ``` 145 + 146 + ### Form Data 147 + 148 + ```go 149 + func loginHandler(w http.ResponseWriter, r *http.Request) { 150 + if r.Method == "GET" { 151 + components.LoginForm().Render(r.Context(), w) 152 + return 153 + } 154 + 155 + // POST 156 + r.ParseForm() 157 + email := r.FormValue("email") 158 + password := r.FormValue("password") 159 + 160 + if authenticate(email, password) { 161 + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) 162 + } else { 163 + components.LoginForm("Invalid credentials").Render(r.Context(), w) 164 + } 165 + } 166 + ``` 167 + 168 + ### Path Parameters 169 + 170 + ```go 171 + // /user/123 172 + func userHandler(w http.ResponseWriter, r *http.Request) { 173 + // Extract ID from path 174 + path := strings.TrimPrefix(r.URL.Path, "/user/") 175 + userID := path 176 + 177 + user := getUserByID(userID) 178 + if user == nil { 179 + http.NotFound(w, r) 180 + return 181 + } 182 + 183 + components.UserProfile(user).Render(r.Context(), w) 184 + } 185 + ``` 186 + 187 + ## Middleware 188 + 189 + ### Logging Middleware 190 + 191 + ```go 192 + func loggingMiddleware(next http.Handler) http.Handler { 193 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 194 + log.Printf("%s %s", r.Method, r.URL.Path) 195 + next.ServeHTTP(w, r) 196 + }) 197 + } 198 + 199 + func main() { 200 + mux := http.NewServeMux() 201 + mux.HandleFunc("/", homeHandler) 202 + 203 + http.ListenAndServe(":8080", loggingMiddleware(mux)) 204 + } 205 + ``` 206 + 207 + ### Auth Middleware 208 + 209 + ```go 210 + func authMiddleware(next http.HandlerFunc) http.HandlerFunc { 211 + return func(w http.ResponseWriter, r *http.Request) { 212 + session := getSession(r) 213 + if session == nil { 214 + http.Redirect(w, r, "/login", http.StatusSeeOther) 215 + return 216 + } 217 + 218 + next(w, r) 219 + } 220 + } 221 + 222 + // Usage 223 + http.HandleFunc("/dashboard", authMiddleware(dashboardHandler)) 224 + ``` 225 + 226 + ## Error Handling 227 + 228 + ### Custom Error Pages 229 + 230 + ```go 231 + func errorHandler(w http.ResponseWriter, r *http.Request, status int, message string) { 232 + w.WriteHeader(status) 233 + components.ErrorPage(status, message).Render(r.Context(), w) 234 + } 235 + 236 + func userHandler(w http.ResponseWriter, r *http.Request) { 237 + user, err := getUserByID(r.URL.Query().Get("id")) 238 + if err != nil { 239 + errorHandler(w, r, 500, "Failed to load user") 240 + return 241 + } 242 + if user == nil { 243 + errorHandler(w, r, 404, "User not found") 244 + return 245 + } 246 + 247 + components.UserProfile(user).Render(r.Context(), w) 248 + } 249 + ``` 250 + 251 + ### Error Component 252 + 253 + ```templ 254 + // components/error.templ 255 + package components 256 + 257 + templ ErrorPage(code int, message string) { 258 + @Layout("Error") { 259 + <div class="error-page"> 260 + <h1>{ strconv.Itoa(code) }</h1> 261 + <p>{ message }</p> 262 + <a href="/">Go Home</a> 263 + </div> 264 + } 265 + } 266 + ``` 267 + 268 + ## Static Files 269 + 270 + ```go 271 + func main() { 272 + mux := http.NewServeMux() 273 + 274 + // Serve static files 275 + fs := http.FileServer(http.Dir("static")) 276 + mux.Handle("/static/", http.StripPrefix("/static/", fs)) 277 + 278 + // Routes 279 + mux.HandleFunc("/", homeHandler) 280 + 281 + http.ListenAndServe(":8080", mux) 282 + } 283 + ``` 284 + 285 + ## Context Usage 286 + 287 + ### Passing Data via Context 288 + 289 + ```go 290 + func contextMiddleware(next http.Handler) http.Handler { 291 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 292 + ctx := context.WithValue(r.Context(), "userID", "123") 293 + next.ServeHTTP(w, r.WithContext(ctx)) 294 + }) 295 + } 296 + 297 + func handler(w http.ResponseWriter, r *http.Request) { 298 + userID := r.Context().Value("userID").(string) 299 + 300 + components.Page(userID).Render(r.Context(), w) 301 + } 302 + ``` 303 + 304 + ## Full Example 305 + 306 + ```go 307 + package main 308 + 309 + import ( 310 + "log" 311 + "net/http" 312 + "myapp/components" 313 + ) 314 + 315 + func main() { 316 + mux := http.NewServeMux() 317 + 318 + // Static files 319 + fs := http.FileServer(http.Dir("static")) 320 + mux.Handle("/static/", http.StripPrefix("/static/", fs)) 321 + 322 + // Routes 323 + mux.HandleFunc("/", homeHandler) 324 + mux.HandleFunc("/about", aboutHandler) 325 + mux.HandleFunc("/contact", contactHandler) 326 + 327 + // Start server 328 + addr := ":8080" 329 + log.Printf("Server starting on %s", addr) 330 + if err := http.ListenAndServe(addr, mux); err != nil { 331 + log.Fatal(err) 332 + } 333 + } 334 + 335 + func homeHandler(w http.ResponseWriter, r *http.Request) { 336 + components.HomePage().Render(r.Context(), w) 337 + } 338 + 339 + func aboutHandler(w http.ResponseWriter, r *http.Request) { 340 + components.AboutPage().Render(r.Context(), w) 341 + } 342 + 343 + func contactHandler(w http.ResponseWriter, r *http.Request) { 344 + if r.Method == "GET" { 345 + components.ContactForm().Render(r.Context(), w) 346 + return 347 + } 348 + 349 + // POST 350 + r.ParseForm() 351 + name := r.FormValue("name") 352 + email := r.FormValue("email") 353 + message := r.FormValue("message") 354 + 355 + // Send email... 356 + 357 + components.ContactSuccess(name).Render(r.Context(), w) 358 + } 359 + ``` 360 + 361 + ## Best Practices 362 + 363 + 1. **Always use r.Context()** when rendering 364 + 2. **Handle errors** from Render() 365 + 3. **Set appropriate status codes** before rendering 366 + 4. **Use middleware** for common functionality 367 + 5. **Separate routes** from handler logic 368 + 6. **Return early** on errors 369 + 370 + ## Common Patterns 371 + 372 + ### Pattern: Redirect After Post 373 + 374 + ```go 375 + func formHandler(w http.ResponseWriter, r *http.Request) { 376 + if r.Method == "GET" { 377 + components.Form().Render(r.Context(), w) 378 + return 379 + } 380 + 381 + // POST - process form 382 + processForm(r) 383 + 384 + // Redirect 385 + http.Redirect(w, r, "/success", http.StatusSeeOther) 386 + } 387 + ``` 388 + 389 + ### Pattern: JSON API + HTML 390 + 391 + ```go 392 + func usersHandler(w http.ResponseWriter, r *http.Request) { 393 + users := getUsers() 394 + 395 + // Check Accept header 396 + if r.Header.Get("Accept") == "application/json" { 397 + w.Header().Set("Content-Type", "application/json") 398 + json.NewEncoder(w).Encode(users) 399 + return 400 + } 401 + 402 + // Default: HTML 403 + components.UserList(users).Render(r.Context(), w) 404 + } 405 + ``` 406 + 407 + ## Next Steps 408 + 409 + - **Add interactivity** → Use `templ-htmx` skill
+354
.claude/skills/templ-syntax/SKILL.md
··· 1 + --- 2 + name: templ-syntax 3 + description: Learn and write templ component syntax including expressions, conditionals, loops, and Go integration. Use when writing .templ files, learning templ syntax, or mentions 'templ component', 'templ expressions', or '.templ file syntax'. 4 + --- 5 + 6 + # Templ Syntax 7 + 8 + ## Overview 9 + 10 + Templ syntax combines Go code with HTML markup in `.templ` files. Components are compiled to Go functions, giving you type safety and IDE support. 11 + 12 + ## When to Use This Skill 13 + 14 + Use when: 15 + 16 + - Writing `.templ` component files 17 + - Learning templ syntax and expressions 18 + - User mentions "templ syntax", "component definition", ".templ file" 19 + - Converting HTML to templ components 20 + 21 + ## Basic Syntax 22 + 23 + ### Component Definition 24 + 25 + ```templ 26 + package components 27 + 28 + templ ComponentName(param1 type1, param2 type2) { 29 + <div>Content</div> 30 + } 31 + ``` 32 + 33 + **Rules:** 34 + 35 + - Start with `package` declaration 36 + - Use `templ` keyword for component 37 + - PascalCase for exported components 38 + - camelCase for internal components 39 + 40 + ### Expressions 41 + 42 + Output Go variables with `{ }`: 43 + 44 + ```templ 45 + templ Greeting(name string, age int) { 46 + <p>Hello, { name }!</p> 47 + <p>You are { strconv.Itoa(age) } years old</p> 48 + } 49 + ``` 50 + 51 + **Expression rules:** 52 + 53 + - `{ variable }` outputs text (auto-escaped) 54 + - Can call Go functions: `{ strings.ToUpper(name) }` 55 + - Must return string or implement `fmt.Stringer` 56 + 57 + ### HTML Elements 58 + 59 + Write HTML directly: 60 + 61 + ```templ 62 + templ Card(title string) { 63 + <div class="card"> 64 + <h2>{ title }</h2> 65 + <p>Content goes here</p> 66 + </div> 67 + } 68 + ``` 69 + 70 + ### Attributes 71 + 72 + Static attributes: 73 + 74 + ```templ 75 + <div class="container" id="main"> 76 + ``` 77 + 78 + Dynamic attributes: 79 + 80 + ```templ 81 + templ Button(id string, disabled bool) { 82 + <button 83 + id={ id } 84 + disabled?={ disabled } 85 + class={ getButtonClass() } 86 + > 87 + Click 88 + </button> 89 + } 90 + ``` 91 + 92 + **Attribute syntax:** 93 + 94 + - `attr={ value }` - dynamic value 95 + - `attr?={ bool }` - conditional attribute 96 + - Use quotes for static: `class="btn"` 97 + 98 + ### Conditional Rendering 99 + 100 + If/else: 101 + 102 + ```templ 103 + templ Alert(message string, isError bool) { 104 + if isError { 105 + <div class="alert-error">{ message }</div> 106 + } else { 107 + <div class="alert-info">{ message }</div> 108 + } 109 + } 110 + ``` 111 + 112 + Multiple conditions: 113 + 114 + ```templ 115 + templ Status(code int) { 116 + if code < 300 { 117 + <span class="success">Success</span> 118 + } else if code < 400 { 119 + <span class="redirect">Redirect</span> 120 + } else { 121 + <span class="error">Error</span> 122 + } 123 + } 124 + ``` 125 + 126 + ### Loops 127 + 128 + For loop: 129 + 130 + ```templ 131 + templ List(items []string) { 132 + <ul> 133 + for _, item := range items { 134 + <li>{ item }</li> 135 + } 136 + </ul> 137 + } 138 + ``` 139 + 140 + With index: 141 + 142 + ```templ 143 + templ NumberedList(items []string) { 144 + <ol> 145 + for i, item := range items { 146 + <li>{ strconv.Itoa(i+1) }. { item }</li> 147 + } 148 + </ol> 149 + } 150 + ``` 151 + 152 + ### Switch Statements 153 + 154 + ```templ 155 + templ Badge(status string) { 156 + switch status { 157 + case "active": 158 + <span class="badge-green">Active</span> 159 + case "pending": 160 + <span class="badge-yellow">Pending</span> 161 + case "inactive": 162 + <span class="badge-gray">Inactive</span> 163 + default: 164 + <span class="badge-default">Unknown</span> 165 + } 166 + } 167 + ``` 168 + 169 + ### Component Composition 170 + 171 + Call other components with `@`: 172 + 173 + ```templ 174 + templ Layout(title string) { 175 + <!DOCTYPE html> 176 + <html> 177 + <head> 178 + <title>{ title }</title> 179 + </head> 180 + <body> 181 + @Header() 182 + <main> 183 + { children... } 184 + </main> 185 + @Footer() 186 + </body> 187 + </html> 188 + } 189 + 190 + templ Header() { 191 + <header> 192 + <h1>My Site</h1> 193 + </header> 194 + } 195 + 196 + templ Footer() { 197 + <footer> 198 + <p>&copy; 2024</p> 199 + </footer> 200 + } 201 + ``` 202 + 203 + **Usage:** 204 + 205 + ```templ 206 + templ HomePage() { 207 + @Layout("Home") { 208 + <p>Welcome to home page</p> 209 + } 210 + } 211 + ``` 212 + 213 + ### Children 214 + 215 + Accept child content: 216 + 217 + ```templ 218 + templ Card(title string) { 219 + <div class="card"> 220 + <h3>{ title }</h3> 221 + <div class="card-body"> 222 + { children... } 223 + </div> 224 + </div> 225 + } 226 + ``` 227 + 228 + Use: 229 + 230 + ```templ 231 + templ Profile() { 232 + @Card("User Profile") { 233 + <p>Name: John Doe</p> 234 + <p>Email: john@example.com</p> 235 + } 236 + } 237 + ``` 238 + 239 + ### CSS Blocks 240 + 241 + Inline styles: 242 + 243 + ```templ 244 + templ StyledComponent() { 245 + <style> 246 + .custom-class { 247 + color: blue; 248 + font-size: 16px; 249 + } 250 + </style> 251 + <div class="custom-class">Styled content</div> 252 + } 253 + ``` 254 + 255 + ### Script Blocks 256 + 257 + Inline JavaScript: 258 + 259 + ```templ 260 + templ Interactive() { 261 + <button id="myBtn">Click me</button> 262 + 263 + <script> 264 + document.getElementById('myBtn').addEventListener('click', function() { 265 + alert('Clicked!'); 266 + }); 267 + </script> 268 + } 269 + ``` 270 + 271 + ### Comments 272 + 273 + Go-style comments: 274 + 275 + ```templ 276 + package components 277 + 278 + // Card component displays a card 279 + templ Card(title string) { 280 + // Main card container 281 + <div class="card"> 282 + { /* This is a block comment */ } 283 + <h3>{ title }</h3> 284 + </div> 285 + } 286 + ``` 287 + 288 + ## Quick Reference 289 + 290 + | Syntax | Purpose | Example | 291 + | ----------------- | --------------------- | -------------------------------- | 292 + | `{ expr }` | Output expression | `{ name }` | 293 + | `attr={ val }` | Dynamic attribute | `id={ userId }` | 294 + | `attr?={ bool }` | Conditional attribute | `disabled?={ isDisabled }` | 295 + | `@Component()` | Call component | `@Header()` | 296 + | `{ children... }` | Accept children | `{ children... }` | 297 + | `if/else` | Conditional | `if isAdmin { }` | 298 + | `for range` | Loop | `for _, item := range items { }` | 299 + | `switch` | Switch statement | `switch status { case "ok": }` | 300 + 301 + ## Common Patterns 302 + 303 + ### Pattern 1: Conditional Classes 304 + 305 + ```templ 306 + templ Button(text string, isPrimary bool) { 307 + <button class={ templ.KV("btn-primary", isPrimary) }> 308 + { text } 309 + </button> 310 + } 311 + ``` 312 + 313 + ### Pattern 2: List with Empty State 314 + 315 + ```templ 316 + templ ItemList(items []string) { 317 + if len(items) == 0 { 318 + <p>No items found</p> 319 + } else { 320 + <ul> 321 + for _, item := range items { 322 + <li>{ item }</li> 323 + } 324 + </ul> 325 + } 326 + } 327 + ``` 328 + 329 + ### Pattern 3: Data Attributes 330 + 331 + ```templ 332 + templ DataCard(id string, value int) { 333 + <div 334 + data-id={ id } 335 + data-value={ strconv.Itoa(value) } 336 + > 337 + Content 338 + </div> 339 + } 340 + ``` 341 + 342 + ## Best Practices 343 + 344 + 1. **Keep components small** - One component, one purpose 345 + 2. **Use type-safe params** - Leverage Go's type system 346 + 3. **Avoid complex logic** - Move to Go functions 347 + 4. **Consistent naming** - PascalCase for exports 348 + 5. **Escape when needed** - Use `{ }` for auto-escaping 349 + 350 + ## Next Steps 351 + 352 + - **Build components** → Use `templ-components` skill 353 + - **Connect to HTTP** → Use `templ-http` skill 354 + - **Add interactivity** → Use `templ-htmx` skill
+3 -1
.github/workflows/earthly.yml
··· 10 10 11 11 jobs: 12 12 build: 13 - runs-on: alrest-xe-site 13 + runs-on: ubuntu-latest 14 14 permissions: 15 15 contents: read 16 16 packages: write ··· 39 39 set: | 40 40 github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest 41 41 patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest 42 + sponsor-panel.tags=ghcr.io/xe/site/sponsor-panel:latest 42 43 xesite.tags=ghcr.io/xe/site/bin:latest 43 44 44 45 - name: Build Docker image ··· 50 51 set: | 51 52 github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest 52 53 patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest 54 + sponsor-panel.tags=ghcr.io/xe/site/sponsor-panel:latest 53 55 xesite.tags=ghcr.io/xe/site/bin:latest
+15
cmd/sponsor-panel/AGENTS.md
··· 1 + # Sponsor panel service 2 + 3 + This service is a simple sponsorship administrative panel. To find out more, read the executive summary at ./docs/README.md. 4 + 5 + ## Building this service 6 + 7 + ```bash 8 + go build -o /dev/null . 9 + ``` 10 + 11 + ## Running this service 12 + 13 + ```bash 14 + npm run dev:sponsor-panel 15 + ```
+1
cmd/sponsor-panel/CLAUDE.md
··· 1 + @AGENTS.md
+104
cmd/sponsor-panel/dashboard.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + 8 + "github.com/a-h/templ" 9 + "xeiaso.net/v4/cmd/sponsor-panel/templates" 10 + ) 11 + 12 + // loginPageHandler renders the login page. 13 + func (s *Server) loginPageHandler(w http.ResponseWriter, r *http.Request) { 14 + if r.Method != http.MethodGet { 15 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 16 + return 17 + } 18 + 19 + slog.Debug("loginPageHandler: rendering login page") 20 + 21 + // If already authenticated, redirect to dashboard 22 + if user, err := s.getSessionUser(r); err == nil { 23 + slog.Debug("loginPageHandler: user already authenticated, redirecting to dashboard", "user_id", user.ID, "login", user.Login) 24 + http.Redirect(w, r, "/", http.StatusFound) 25 + return 26 + } 27 + 28 + templ.Handler( 29 + templates.Base("Login", templates.Login()), 30 + ).ServeHTTP(w, r) 31 + } 32 + 33 + // dashboardHandler renders the main dashboard for authenticated sponsors. 34 + func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { 35 + if r.Method != http.MethodGet { 36 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 37 + return 38 + } 39 + 40 + // Check authentication 41 + user, err := s.getSessionUser(r) 42 + if err != nil { 43 + slog.Debug("dashboardHandler: unauthenticated user, showing login page", "err", err) 44 + // Show login page instead of redirecting 45 + s.loginPageHandler(w, r) 46 + return 47 + } 48 + 49 + slog.Debug("dashboardHandler: authenticated user", "user_id", user.ID, "login", user.Login) 50 + 51 + // Check sponsorship tiers 52 + isFiftyPlus := user.IsSponsorAtTier(5000) // $50 = 5000 cents 53 + isSponsor := user.IsSponsorAtTier(100) // $1 = 100 cents 54 + 55 + slog.Debug("dashboardHandler: sponsorship tier check", 56 + "user_id", user.ID, 57 + "is_sponsor", isSponsor, 58 + "is_fifty_plus", isFiftyPlus) 59 + 60 + // Parse sponsorship data for display 61 + monthlyAmount := 0 62 + tierName := "Sponsor" 63 + if user.SponsorshipData != "" { 64 + var data SponsorshipData 65 + if err := json.Unmarshal([]byte(user.SponsorshipData), &data); err == nil { 66 + if data.IsActive { 67 + monthlyAmount = data.MonthlyAmount 68 + tierName = data.TierName 69 + if tierName == "" { 70 + tierName = "Sponsor" 71 + } 72 + } 73 + slog.Debug("dashboardHandler: parsed sponsorship data", 74 + "user_id", user.ID, 75 + "is_active", data.IsActive, 76 + "monthly_amount", monthlyAmount, 77 + "tier_name", tierName) 78 + } else { 79 + slog.Error("dashboardHandler: failed to parse sponsorship data", "err", err, "user_id", user.ID, "raw_data", user.SponsorshipData) 80 + } 81 + } else { 82 + slog.Debug("dashboardHandler: no sponsorship data", "user_id", user.ID) 83 + } 84 + 85 + props := templates.DashboardProps{ 86 + User: templates.UserProps{ 87 + Login: user.Login, 88 + AvatarURL: user.AvatarURL, 89 + }, 90 + IsSponsor: isSponsor, 91 + SponsorAmount: monthlyAmount, 92 + SponsorTier: tierName, 93 + IsFiftyPlus: isFiftyPlus, 94 + DiscordInvite: s.discordInvite, 95 + } 96 + 97 + slog.Debug("dashboardHandler: rendering dashboard", "user_id", user.ID, "login", user.Login) 98 + 99 + w.Header().Set("Content-Type", "text/html") 100 + 101 + templ.Handler( 102 + templates.Base("Dashboard", templates.Dashboard(props)), 103 + ).ServeHTTP(w, r) 104 + }
+246
cmd/sponsor-panel/docs/README.md
··· 1 + # Sponsor Panel Service - Executive Summary 2 + 3 + **Service:** sponsor-panel 4 + **Exposure:** sponsors.xeiaso.net 5 + **Status:** Specification Complete (Simplified) 6 + 7 + --- 8 + 9 + ## Purpose 10 + 11 + The sponsor-panel service provides GitHub sponsors with a self-service dashboard to manage their sponsorship benefits. The service verifies sponsorship status via GitHub Sponsors GraphQL API and unlocks features based on contribution tier. 12 + 13 + **Design philosophy:** Build the simplest thing that works. Both technical and UX specifications have been ruthlessly simplified from original over-engineered versions. 14 + 15 + --- 16 + 17 + ## Feature Matrix 18 + 19 + | Feature | $0 | $1-49 | $50+ | 20 + | ----------------------------------------- | :-: | :---: | :--: | 21 + | Discord invite link | ✅ | ✅ | ✅ | 22 + | Sponsorship status display | - | ✅ | ✅ | 23 + | Team invitation to `botstopper-customers` | - | - | ✅ | 24 + | Logo submission for Anubis README | - | ✅ | ✅ | 25 + 26 + **Removed from original specs:** 27 + 28 + - Organization-specific teams, Invitation audit trail, Logo approval workflow, Image processing, Real-time validation, Activity feeds, Multiple pages, Toast notifications, Locked/unlocked states 29 + 30 + --- 31 + 32 + ## Technical Stack (Simplified) 33 + 34 + | Layer | Technology | Notes | 35 + | ------------ | --------------------------- | --------------------- | 36 + | **Backend** | Go with `net/http` | Standard library only | 37 + | **Frontend** | Templ + HTMX + Tailwind CSS | Unchanged | 38 + | **Database** | PostgreSQL with sqlx | No ORM | 39 + | **Storage** | GitHub issue attachments | No external storage | 40 + | **Auth** | GitHub OAuth 2.0 | No PKCE (server-side) | 41 + | **Sessions** | Cookie-only | No database backing | 42 + 43 + **Removed dependencies:** gorilla/sessions, disintegration/imaging, aws-sdk-go-v2, gorm 44 + 45 + --- 46 + 47 + ## Complexity Reduction 48 + 49 + | Aspect | Original | Simplified | Change | 50 + | --------------------- | -------- | ---------- | ------ | 51 + | **Tables** | 6 | 2 | -67% | 52 + | **Dependencies** | 15+ | 6 | -60% | 53 + | **Endpoints** | 10+ | 5 | -50% | 54 + | **Pages** | 7+ | 3 | -57% | 55 + | **Components** | 20+ | 5 | -75% | 56 + | **External services** | 3 | 2 | -33% | 57 + | **Code estimate** | ~4000 | ~1500 | -62% | 58 + 59 + --- 60 + 61 + ## Architecture Overview 62 + 63 + ``` 64 + ┌─────────────────────────────────────────────────────────────────────┐ 65 + │ USER BROWSER (HTMX) │ 66 + └─────────────────────────────┬───────────────────────────────────────┘ 67 + 68 + 69 + ┌───────────────────────────────────────────────────────────────────┐ 70 + │ sponsor-panel Service │ 71 + │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ 72 + │ │ OAuth │ │ Sponsor │ │ Team │ │ Logo │ │ 73 + │ │ Handler │ │ Checker │ │ Inviter │ │ Submitter │ │ 74 + │ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ │ 75 + └─────────┼───────────────┼───────────────┼───────────────┼─────────┘ 76 + │ │ │ │ 77 + ▼ ▼ ▼ ▼ 78 + ┌─────────────────────────────────────────────────────────────────────┐ 79 + │ sqlx (PostgreSQL) │ 80 + │ • users • logo_submissions │ 81 + └─────────────────────────────────────────────────────────────────────┘ 82 + │ │ │ 83 + ▼ ▼ ▼ 84 + ┌─────────────────────────────────────────────────────────────────────┐ 85 + │ GitHub APIs │ 86 + │ • OAuth 2.0 • GraphQL (Sponsors) • REST (Teams, Issues) │ 87 + └─────────────────────────────────────────────────────────────────────┘ 88 + ``` 89 + 90 + --- 91 + 92 + ## Data Model (Simplified) 93 + 94 + | Model | Purpose | 95 + | ---------------- | ------------------------------------------------------ | 96 + | `User` | GitHub OAuth users with JSON-embedded sponsorship data | 97 + | `LogoSubmission` | Logo submissions with GitHub issue tracking | 98 + 99 + **Removed tables:** Organization, SponsorshipCache, TeamInvitation, Session, OAuthState 100 + 101 + --- 102 + 103 + ## Key Integrations (Simplified) 104 + 105 + ### GitHub OAuth 106 + 107 + - Scopes: `read:user`, `user:email`, `read:org`, `read:sponsors` 108 + - Session: Encrypted cookie only (no database) 109 + 110 + ### GitHub Sponsors (GraphQL) 111 + 112 + - Single query on login, cached in user table 113 + - No separate cache table or expiration logic 114 + 115 + ### Team Management 116 + 117 + - Target: `TecharoHQ/botstopper-customers` only 118 + - No audit trail or invitation history 119 + 120 + ### Logo Submission 121 + 122 + - Create GitHub issue in `TecharoHQ/anubis` 123 + - Attach logo directly to issue (no external storage) 124 + 125 + --- 126 + 127 + ## Endpoints (Simplified) 128 + 129 + | Endpoint | Method | Auth | Purpose | 130 + | ----------- | ------ | ---- | ------------------------------ | 131 + | `/login` | GET | No | Initiate GitHub OAuth | 132 + | `/callback` | GET | No | OAuth callback, create session | 133 + | `/` | GET | Yes | Dashboard | 134 + | `/invite` | POST | Yes | Invite user to team | 135 + | `/logo` | POST | Yes | Submit logo (create issue) | 136 + 137 + --- 138 + 139 + ## UX Overview (Simplified) 140 + 141 + ### Pages (3 total) 142 + 143 + 1. **Login page** - GitHub OAuth button 144 + 2. **Dashboard** - All sections on one page 145 + 3. **OAuth error** - Simple error page 146 + 147 + ### Components (5 total) 148 + 149 + - `base.templ` - Layout wrapper 150 + - `Login.templ` - Login page 151 + - `Dashboard.templ` - Main dashboard 152 + - `OAuthError.templ` - Error page 153 + - `FormResult.templ` - Form success/error messages 154 + 155 + ### Dashboard Sections 156 + 157 + - Discord invite (always visible) 158 + - Sponsorship status 159 + - Team invitation ($50+ sponsors only) 160 + - Logo submission (all sponsors) 161 + 162 + --- 163 + 164 + ## Configuration (Simplified) 165 + 166 + ### Required Environment Variables 167 + 168 + | Variable | Purpose | 169 + | ---------------------- | ------------------------------- | 170 + | `DATABASE_URL` | PostgreSQL connection | 171 + | `GITHUB_CLIENT_ID` | OAuth app ID | 172 + | `GITHUB_CLIENT_SECRET` | OAuth app secret | 173 + | `GITHUB_TOKEN` | GitHub token for team/issue ops | 174 + | `SESSION_KEY` | Session encryption key | 175 + | `DISCORD_INVITE` | Discord invite link | 176 + | `OAUTH_REDIRECT_URL` | OAuth callback URL | 177 + 178 + --- 179 + 180 + ## Documentation 181 + 182 + | Document | Lines | Description | 183 + | ------------------------ | ----- | --------------------------------------------------------------- | 184 + | **SPEC.md** | 975 | **(Simplified)** Technical: 2 tables, 5 endpoints, sqlx, no ORM | 185 + | **UX_FLOWS.md** | 522 | **(Simplified)** UX: 3 pages, 5 components, simple forms | 186 + | **ORIGINAL_SPEC.md** | 909 | Original technical spec (over-engineered reference) | 187 + | **ORIGINAL_UX_FLOWS.md** | 1,562 | Original UX spec (over-engineered reference) | 188 + 189 + --- 190 + 191 + ## Implementation Checklist 192 + 193 + ### Phase 1: Foundation 194 + 195 + - [ ] Create `internal/models/` with User and LogoSubmission structs 196 + - [ ] Set up sqlx with PostgreSQL 197 + - [ ] Create migration for 2 tables 198 + - [ ] Implement cookie encryption/decryption 199 + 200 + ### Phase 2: Authentication 201 + 202 + - [ ] Implement `/login` OAuth redirect 203 + - [ ] Implement `/callback` with user creation 204 + - [ ] Add session cookie handling 205 + - [ ] Create logout handler 206 + 207 + ### Phase 3: Sponsorship 208 + 209 + - [ ] Implement GraphQL client 210 + - [ ] Cache sponsorship data in user table 211 + - [ ] Add `IsSponsorAtTier()` helper method 212 + 213 + ### Phase 4: Dashboard & Features 214 + 215 + - [ ] Build 5 Templ components 216 + - [ ] Implement `/invite` with GitHub API 217 + - [ ] Implement `/logo` with GitHub issue creation 218 + - [ ] Add error handling components 219 + 220 + ### Phase 5: Testing & Deploy 221 + 222 + - [ ] Unit tests for handlers 223 + - [ ] Integration tests with test database 224 + - [ ] Deploy to Kubernetes 225 + 226 + --- 227 + 228 + ## Next Steps 229 + 230 + 1. **Review the simplified specs:** 231 + - `SPEC.md` - Technical implementation 232 + - `UX_FLOWS.md` - UI/UX components and flows 233 + 234 + 2. **Set up development:** 235 + - Configure PostgreSQL database 236 + - Create GitHub OAuth app 237 + - No storage setup needed (uses GitHub) 238 + 239 + 3. **Begin implementation:** 240 + - Start with Phase 1 (Foundation) 241 + - Follow the simplified specs 242 + - Build incrementally 243 + 244 + --- 245 + 246 + _Simplified specifications based on ruthless code review, 2026-02-07_
+975
cmd/sponsor-panel/docs/SPEC.md
··· 1 + # Sponsor Panel Service - Simplified Specification 2 + 3 + **Service:** sponsor-panel 4 + **Exposure:** sponsors.xeiaso.net 5 + **Design Philosophy:** Minimal, working service without over-engineering 6 + 7 + --- 8 + 9 + ## Overview 10 + 11 + This is a **simplified version** of the sponsor-panel specification. The original spec (see `SPEC.md` and `UX_FLOWS.md`) contained significant over-engineering. This version removes unnecessary complexity while delivering the same core value. 12 + 13 + **Core principle:** Build the simplest thing that works. Add complexity only when proven necessary. 14 + 15 + --- 16 + 17 + ## Features (Simplified) 18 + 19 + | Feature | $0 | $1-49 | $50+ | Implementation | 20 + | ------------------- | :-: | :---: | :--: | ------------------------------------------ | 21 + | Discord invite link | ✅ | ✅ | ✅ | Environment variable, display on dashboard | 22 + | Sponsorship status | - | ✅ | ✅ | GraphQL on login, store in session | 23 + | Team invitation | - | - | ✅ | Direct GitHub API call, no audit trail | 24 + | Logo submission | - | ✅ | ✅ | Create GitHub issue with attachment | 25 + 26 + **Removed from original spec:** 27 + 28 + - ❌ Organization-specific team invitations 29 + - ❌ Invitation history/audit trail 30 + - ❌ Logo approval workflow 31 + - ❌ Multiple image sizes 32 + - ❌ Image resizing/optimization 33 + - ❌ Real-time username validation 34 + - ❌ Activity feed 35 + - ❌ Admin panel 36 + 37 + --- 38 + 39 + ## Tech Stack (Simplified) 40 + 41 + | Layer | Technology | Notes | 42 + | ------------ | --------------------------- | --------------------- | 43 + | **Backend** | Go with `net/http` | Standard library only | 44 + | **Frontend** | Templ + HTMX + Tailwind CSS | Unchanged | 45 + | **Database** | PostgreSQL with sqlx | No ORM | 46 + | **Storage** | GitHub issue attachments | No external storage | 47 + | **Auth** | GitHub OAuth 2.0 | No PKCE (server-side) | 48 + | **Sessions** | Cookie-only | No database backing | 49 + 50 + ### Removed Dependencies 51 + 52 + ``` 53 + - github.com/gorilla/sessions → Use simple cookie store 54 + - github.com/disintegration/imaging → No image processing 55 + - github.com/aws/aws-sdk-go-v2 → Use GitHub attachments 56 + - gorm.io/gorm → Use sqlx 57 + - gorm.io/driver/postgres → Use lib/pq directly 58 + ``` 59 + 60 + --- 61 + 62 + ## Data Model (Simplified) 63 + 64 + ### 2 Tables Instead of 6 65 + 66 + ```sql 67 + -- Users table: GitHub accounts + sponsorship data 68 + CREATE TABLE users ( 69 + id SERIAL PRIMARY KEY, 70 + github_id BIGINT UNIQUE NOT NULL, 71 + login TEXT NOT NULL UNIQUE, 72 + avatar_url TEXT, 73 + name TEXT, 74 + email TEXT, 75 + 76 + -- Sponsorship data from GraphQL (cached) 77 + sponsorship_data JSONB, 78 + last_sponsorship_check TIMESTAMP DEFAULT NOW(), 79 + 80 + -- Session data (encrypted cookie, no DB table) 81 + created_at TIMESTAMP DEFAULT NOW(), 82 + updated_at TIMESTAMP DEFAULT NOW() 83 + ); 84 + 85 + -- Logo submissions: Simple tracking only 86 + CREATE TABLE logo_submissions ( 87 + id SERIAL PRIMARY KEY, 88 + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, 89 + 90 + company_name TEXT NOT NULL, 91 + website TEXT NOT NULL, 92 + logo_url TEXT, -- GitHub issue attachment URL 93 + github_issue_url TEXT, 94 + github_issue_number INTEGER, 95 + 96 + submitted_at TIMESTAMP DEFAULT NOW() 97 + ); 98 + 99 + -- Indexes for common queries 100 + CREATE INDEX idx_users_github_id ON users(github_id); 101 + CREATE INDEX idx_users_login ON users(login); 102 + CREATE INDEX idx_logo_user_id ON logo_submissions(user_id); 103 + ``` 104 + 105 + ### Go Models 106 + 107 + ```go 108 + package models 109 + 110 + import "time" 111 + 112 + // User represents a GitHub user with cached sponsorship data 113 + type User struct { 114 + ID int `json:"id" db:"id"` 115 + GitHubID int64 `json:"github_id" db:"github_id"` 116 + Login string `json:"login" db:"login"` 117 + AvatarURL string `json:"avatar_url" db:"avatar_url"` 118 + Name string `json:"name" db:"name"` 119 + Email string `json:"email" db:"email"` 120 + SponsorshipData string `json:"-" db:"sponsorship_data"` // JSON blob 121 + LastSponsorshipCheck time.Time `json:"last_sponsorship_check" db:"last_sponsorship_check"` 122 + CreatedAt time.Time `json:"created_at" db:"created_at"` 123 + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 124 + } 125 + 126 + // SponsorshipData represents the cached GraphQL response 127 + type SponsorshipData struct { 128 + IsActive bool `json:"is_active"` 129 + MonthlyAmount int `json:"monthly_amount_cents"` 130 + TierName string `json:"tier_name"` 131 + PrivacyLevel string `json:"privacy_level"` 132 + } 133 + 134 + // IsSponsorAtTier returns true if user sponsors at or above the given amount (in cents) 135 + func (u *User) IsSponsorAtTier(minCents int) bool { 136 + if u.SponsorshipData == "" { 137 + return false 138 + } 139 + 140 + var data SponsorshipData 141 + if err := json.Unmarshal([]byte(u.SponsorshipData), &data); err != nil { 142 + return false 143 + } 144 + 145 + return data.IsActive && data.MonthlyAmount >= minCents 146 + } 147 + 148 + // LogoSubmission represents a logo submission 149 + type LogoSubmission struct { 150 + ID int `json:"id" db:"id"` 151 + UserID int `json:"user_id" db:"user_id"` 152 + CompanyName string `json:"company_name" db:"company_name"` 153 + Website string `json:"website" db:"website"` 154 + LogoURL string `json:"logo_url" db:"logo_url"` 155 + GitHubIssueURL string `json:"github_issue_url" db:"github_issue_url"` 156 + GitHubIssueNumber int `json:"github_issue_number" db:"github_issue_number"` 157 + SubmittedAt time.Time `json:"submitted_at" db:"submitted_at"` 158 + } 159 + ``` 160 + 161 + --- 162 + 163 + ## Architecture (Simplified) 164 + 165 + ``` 166 + ┌─────────────────────────────────────────────────────────────────────┐ 167 + │ USER BROWSER (HTMX) │ 168 + └─────────────────────────────┬───────────────────────────────────────┘ 169 + 170 + 171 + ┌───────────────────────────────────────────────────────────────────┐ 172 + │ sponsor-panel Service │ 173 + ├───────────────────────────────────────────────────────────────────┤ 174 + │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ 175 + │ │ OAuth │ │ Sponsor │ │ Team │ │ Logo │ │ 176 + │ │ Handler │ │ Checker │ │ Inviter │ │ Submitter │ │ 177 + │ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ │ 178 + └─────────┼───────────────┼───────────────┼───────────────┼─────────┘ 179 + │ │ │ │ 180 + ▼ ▼ ▼ ▼ 181 + ┌─────────────────────────────────────────────────────────────────────┐ 182 + │ sqlx (PostgreSQL) │ 183 + │ • users (2 tables) │ 184 + │ • logo_submissions │ 185 + └─────────────────────────────────────────────────────────────────────┘ 186 + │ │ │ 187 + ▼ ▼ ▼ 188 + ┌─────────────────────────────────────────────────────────────────────┐ 189 + │ GitHub APIs │ 190 + │ • OAuth 2.0 │ 191 + │ • GraphQL (Sponsors) │ 192 + │ • REST (Teams, Issues) │ 193 + └─────────────────────────────────────────────────────────────────────┘ 194 + ``` 195 + 196 + --- 197 + 198 + ## Endpoints (Simplified) 199 + 200 + ### 5 Endpoints Total 201 + 202 + | Method | Endpoint | Auth | Purpose | 203 + | ------ | ----------- | ---- | ------------------------------ | 204 + | GET | `/login` | No | Initiate GitHub OAuth | 205 + | GET | `/callback` | No | OAuth callback, create session | 206 + | GET | `/` | Yes | Dashboard | 207 + | POST | `/invite` | Yes | Invite user to team | 208 + | POST | `/logo` | Yes | Submit logo (create issue) | 209 + 210 + ### Endpoint Details 211 + 212 + #### `GET /login` 213 + 214 + Initiate GitHub OAuth flow. 215 + 216 + ```go 217 + func loginHandler(w http.ResponseWriter, r *http.Request) { 218 + // Generate random state 219 + state := generateState() 220 + 221 + // Set state in cookie 222 + http.SetCookie(w, &http.Cookie{ 223 + Name: "oauth_state", 224 + Value: state, 225 + Path: "/", 226 + Secure: true, 227 + HttpOnly: true, 228 + SameSite: http.SameSiteStrictMode, 229 + }) 230 + 231 + // Redirect to GitHub 232 + url := fmt.Sprintf( 233 + "https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=read:user%%2Cuser:email%%2Cread:org%%2Cread:sponsors&state=%s", 234 + *clientID, *redirectURL, state, 235 + ) 236 + http.Redirect(w, r, url, http.StatusFound) 237 + } 238 + ``` 239 + 240 + #### `GET /callback` 241 + 242 + Handle OAuth callback, fetch user + sponsorship data, create session. 243 + 244 + ```go 245 + func callbackHandler(db *sqlx.DB) http.HandlerFunc { 246 + return func(w http.ResponseWriter, r *http.Request) { 247 + // Verify state 248 + stateCookie, _ := r.Cookie("oauth_state") 249 + if r.URL.Query().Get("state") != stateCookie.Value { 250 + http.Error(w, "Invalid state", http.StatusBadRequest) 251 + return 252 + } 253 + 254 + // Exchange code for token 255 + token, err := exchangeCode(r.URL.Query().Get("code")) 256 + if err != nil { 257 + http.Error(w, "Token exchange failed", http.StatusBadRequest) 258 + return 259 + } 260 + 261 + // Fetch user from GitHub 262 + ghUser, err := fetchGitHubUser(token) 263 + if err != nil { 264 + http.Error(w, "Failed to fetch user", http.StatusInternalServerError) 265 + return 266 + } 267 + 268 + // Fetch sponsorship data from GraphQL 269 + sponsorData, err := fetchSponsorship(token) 270 + if err != nil { 271 + // Non-fatal: log but continue 272 + slog.Error("failed to fetch sponsorship", "err", err) 273 + sponsorData = "{}" 274 + } 275 + 276 + // Upsert user in database 277 + var user User 278 + err = db.Get(&user, "SELECT * FROM users WHERE github_id = $1", ghUser.ID) 279 + if err == sql.ErrNoRows { 280 + err = db.QueryRow( 281 + `INSERT INTO users (github_id, login, avatar_url, name, email, sponsorship_data) 282 + VALUES ($1, $2, $3, $4, $5, $6) 283 + RETURNING *`, 284 + ghUser.ID, ghUser.Login, ghUser.AvatarURL, ghUser.Name, ghUser.Email, sponsorData, 285 + ).Scan(&user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 286 + &user.Name, &user.Email, &user.SponsorshipData, &user.LastSponsorshipCheck, 287 + &user.CreatedAt, &user.UpdatedAt) 288 + } else { 289 + db.QueryRow( 290 + `UPDATE users SET login=$1, avatar_url=$2, name=$3, email=$4, sponsorship_data=$5, 291 + last_sponsorship_check=NOW(), updated_at=NOW() WHERE github_id=$6 RETURNING *`, 292 + ghUser.Login, ghUser.AvatarURL, ghUser.Name, ghUser.Email, sponsorData, ghUser.ID, 293 + ).Scan(&user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 294 + &user.Name, &user.Email, &user.SponsorshipData, &user.LastSponsorshipCheck, 295 + &user.CreatedAt, &user.UpdatedAt) 296 + } 297 + 298 + // Create encrypted session cookie 299 + sessionData := fmt.Sprintf("%d|%s", user.ID, user.Login) 300 + encrypted := encrypt(sessionData, *sessionKey) 301 + 302 + http.SetCookie(w, &http.Cookie{ 303 + Name: "session", 304 + Value: encrypted, 305 + Path: "/", 306 + Secure: true, 307 + HttpOnly: true, 308 + SameSite: http.SameSiteStrictMode, 309 + MaxAge: 30 * 24 * 3600, // 30 days 310 + }) 311 + 312 + http.Redirect(w, r, "/", http.StatusFound) 313 + } 314 + } 315 + ``` 316 + 317 + #### `GET /` (Dashboard) 318 + 319 + Render dashboard based on sponsorship tier. 320 + 321 + ```go 322 + func dashboardHandler(db *sqlx.DB) http.HandlerFunc { 323 + return func(w http.ResponseWriter, r *http.Request) { 324 + // Get user from session 325 + session, _ := r.Cookie("session") 326 + if session == nil { 327 + http.Redirect(w, r, "/login", http.StatusFound) 328 + return 329 + } 330 + 331 + decrypted := decrypt(session.Value, *sessionKey) 332 + parts := strings.Split(decrypted, "|") 333 + userID := parts[0] 334 + 335 + // Fetch user 336 + var user User 337 + if err := db.Get(&user, "SELECT * FROM users WHERE id = $1", userID); err != nil { 338 + http.Redirect(w, r, "/login", http.StatusFound) 339 + return 340 + } 341 + 342 + // Render dashboard 343 + isFiftyPlus := user.IsSponsorAtTier(5000) // $50 = 5000 cents 344 + isSponsor := user.IsSponsorAtTier(100) // $1 = 100 cents 345 + 346 + components.Dashboard(components.DashboardProps{ 347 + User: user, 348 + IsSponsor: isSponsor, 349 + IsFiftyPlus: isFiftyPlus, 350 + DiscordInvite: *discordInvite, 351 + }).Render(r.Context(), w) 352 + } 353 + } 354 + ``` 355 + 356 + #### `POST /invite` (Team Invitation) 357 + 358 + Invite a GitHub user to `botstopper-customers` team. 359 + 360 + ```go 361 + func inviteHandler(db *sqlx.DB, ghClient *github.Client) http.HandlerFunc { 362 + return func(w http.ResponseWriter, r *http.Request) { 363 + // Get user from session (verify $50+ sponsor) 364 + user := getUserFromSession(r, db) 365 + if !user.IsSponsorAtTier(5000) { 366 + http.Error(w, "Requires $50+/month sponsorship", http.StatusForbidden) 367 + return 368 + } 369 + 370 + // Parse form 371 + username := r.FormValue("username") 372 + if username == "" { 373 + http.Error(w, "Username required", http.StatusBadRequest) 374 + return 375 + } 376 + 377 + // Strip @ if present 378 + username = strings.TrimPrefix(username, "@") 379 + 380 + // Invite to team (direct call, no audit trail) 381 + membership, _, err := ghClient.Teams.AddTeamMembershipBySlug( 382 + r.Context(), "TecharoHQ", "botstopper-customers", username, nil, 383 + ) 384 + 385 + if err != nil { 386 + // Check if already member or invited 387 + if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "422") { 388 + components.InviteError("User already invited or not found").Render(r.Context(), w) 389 + return 390 + } 391 + http.Error(w, "Failed to invite: "+err.Error(), http.StatusInternalServerError) 392 + return 393 + } 394 + 395 + // Show success (no database storage) 396 + state := "pending" 397 + if membership != nil && membership.State == "active" { 398 + state = "active" 399 + } 400 + 401 + components.InviteSuccess(username, state).Render(r.Context(), w) 402 + } 403 + } 404 + ``` 405 + 406 + #### `POST /logo` (Logo Submission) 407 + 408 + Create GitHub issue with logo attachment. 409 + 410 + ```go 411 + func logoHandler(db *sqlx.DB, ghClient *github.Client) http.HandlerFunc { 412 + return func(w http.ResponseWriter, r *http.Request) { 413 + // Get user from session 414 + user := getUserFromSession(r, db) 415 + 416 + // Parse form 417 + companyName := r.FormValue("company_name") 418 + website := r.FormValue("website") 419 + 420 + // Upload file to GitHub issue as attachment 421 + file, header, err := r.FormFile("logo") 422 + if err != nil { 423 + http.Error(w, "Logo file required", http.StatusBadRequest) 424 + return 425 + } 426 + defer file.Close() 427 + 428 + // Validate size (max 5MB) 429 + if header.Size > 5*1024*1024 { 430 + http.Error(w, "File too large (max 5MB)", http.StatusBadRequest) 431 + return 432 + } 433 + 434 + // Create issue first 435 + issueBody := fmt.Sprintf(`# Logo Submission: %s 436 + 437 + **Submitted by:** @%s 438 + 439 + ## Details 440 + 441 + - **Company:** %s 442 + - **Website:** %s 443 + - **File:** %s 444 + 445 + ## Next Steps 446 + 447 + 1. Review logo 448 + 2. Add to Anubis README 449 + 3. Close this issue 450 + 451 + /label logo-submission 452 + /label needs-review 453 + `, companyName, user.Login, companyName, website, header.Filename) 454 + 455 + issue, _, err := ghClient.Issues.Create(r.Context(), "TecharoHQ", "anubis", &github.IssueRequest{ 456 + Title: github.String("Logo Submission: " + companyName), 457 + Body: github.String(issueBody), 458 + Labels: &[]string{"logo-submission", "needs-review"}, 459 + }) 460 + 461 + if err != nil { 462 + http.Error(w, "Failed to create issue: "+err.Error(), http.StatusInternalServerError) 463 + return 464 + } 465 + 466 + // TODO: Upload file as issue attachment 467 + // Note: GitHub's REST API doesn't support direct file uploads to issues 468 + // Alternative: Upload to repo assets, reference in issue 469 + // For now: create issue with instructions to email logo 470 + 471 + // Store submission record 472 + var submissionID int 473 + err = db.QueryRow( 474 + `INSERT INTO logo_submissions (user_id, company_name, website, github_issue_url, github_issue_number) 475 + VALUES ($1, $2, $3, $4, $5) RETURNING id`, 476 + user.ID, companyName, website, issue.GetHTMLURL(), issue.GetNumber(), 477 + ).Scan(&submissionID) 478 + 479 + if err != nil { 480 + slog.Error("failed to store submission", "err", err) 481 + } 482 + 483 + // Show success 484 + components.LogoSuccess(companyName, issue.GetHTMLURL(), issue.GetNumber()).Render(r.Context(), w) 485 + } 486 + } 487 + ``` 488 + 489 + --- 490 + 491 + ## Configuration (Simplified) 492 + 493 + ### Environment Variables 494 + 495 + | Variable | Purpose | Required | 496 + | ---------------------- | ----------------------------------- | -------- | 497 + | `DATABASE_URL` | PostgreSQL connection | Yes | 498 + | `GITHUB_CLIENT_ID` | OAuth app ID | Yes | 499 + | `GITHUB_CLIENT_SECRET` | OAuth app secret | Yes | 500 + | `GITHUB_TOKEN` | GitHub App token for team/issue ops | Yes | 501 + | `SESSION_KEY` | Session encryption key | Yes | 502 + | `DISCORD_INVITE` | Discord invite link | Yes | 503 + | `OAUTH_REDIRECT_URL` | OAuth callback URL | Yes | 504 + 505 + ### Flags 506 + 507 + ```go 508 + var ( 509 + bind = flag.String("bind", ":4823", "Port to listen on") 510 + databaseURL = flag.String("database-url", "", "Database URL") 511 + clientID = flag.String("github-client-id", "", "GitHub OAuth Client ID") 512 + clientSecret = flag.String("github-client-secret", "", "GitHub OAuth Client Secret") 513 + githubToken = flag.String("github-token", "", "GitHub token for operations") 514 + sessionKey = flag.String("session-key", "", "Session encryption key") 515 + discordInvite = flag.String("discord-invite", "", "Discord invite link") 516 + redirectURL = flag.String("oauth-redirect-url", "", "OAuth redirect URL") 517 + ) 518 + ``` 519 + 520 + --- 521 + 522 + ## GraphQL Queries (Simplified) 523 + 524 + ### Single Query on Login 525 + 526 + ```graphql 527 + query CheckSponsorship { 528 + viewer { 529 + sponsorshipsAsMaintainer(first: 100, includePrivate: true) { 530 + nodes { 531 + sponsorEntity { 532 + ... on User { 533 + login 534 + } 535 + ... on Organization { 536 + login 537 + } 538 + } 539 + tier { 540 + monthlyPriceInCents 541 + name 542 + } 543 + privacyLevel 544 + isActive 545 + } 546 + } 547 + } 548 + } 549 + ``` 550 + 551 + ### Go Implementation 552 + 553 + ```go 554 + func fetchSponsorship(token string) (string, error) { 555 + query := `{"query": "query { viewer { sponsorshipsAsMaintainer(first: 100, includePrivate: true) { nodes { sponsorEntity { ... on User { login } ... on Organization { login } } tier { monthlyPriceInCents name } privacyLevel isActive } } } }"}` 556 + 557 + req, _ := http.NewRequest("POST", "https://api.github.com/graphql", strings.NewReader(query)) 558 + req.Header.Set("Authorization", "Bearer "+token) 559 + req.Header.Set("Content-Type", "application/json") 560 + 561 + resp, err := http.DefaultClient.Do(req) 562 + if err != nil { 563 + return "", err 564 + } 565 + defer resp.Body.Close() 566 + 567 + var result struct { 568 + Data struct { 569 + Viewer struct { 570 + Sponsorships struct { 571 + Nodes []struct { 572 + SponsorEntity struct { 573 + Login string `json:"login"` 574 + } `json:"sponsorEntity"` 575 + Tier struct { 576 + MonthlyPriceInCents int `json:"monthlyPriceInCents"` 577 + Name string `json:"name"` 578 + } `json:"tier"` 579 + IsActive bool `json:"isActive"` 580 + PrivacyLevel string `json:"privacyLevel"` 581 + } `json:"nodes"` 582 + } `json:"sponsorshipsAsMaintainer"` 583 + } `json:"viewer"` 584 + } `json:"data"` 585 + } 586 + 587 + json.NewDecoder(resp.Body).Decode(&result) 588 + 589 + // Return highest tier sponsorship as JSON 590 + if len(result.Data.Viewer.Sponsorships.Nodes) == 0 { 591 + return `{"is_active": false}`, nil 592 + } 593 + 594 + // Find highest active tier 595 + var highest *struct { 596 + MonthlyPriceInCents int 597 + Name string 598 + } 599 + 600 + for _, node := range result.Data.Viewer.Sponsorships.Nodes { 601 + if node.IsActive && (highest == nil || node.Tier.MonthlyPriceInCents > highest.MonthlyPriceInCents) { 602 + highest = &node.Tier 603 + } 604 + } 605 + 606 + if highest == nil { 607 + return `{"is_active": false}`, nil 608 + } 609 + 610 + resultJSON, _ := json.Marshal(map[string]interface{}{ 611 + "is_active": true, 612 + "monthly_amount_cents": highest.MonthlyPriceInCents, 613 + "tier_name": highest.Name, 614 + }) 615 + 616 + return string(resultJSON), nil 617 + } 618 + ``` 619 + 620 + --- 621 + 622 + ## Session Management (Simplified) 623 + 624 + ### Cookie-Only Sessions 625 + 626 + ```go 627 + // Simple encryption using crypto/aes 628 + func encrypt(plaintext, key string) string { 629 + block, _ := aes.NewCipher([]byte(key)) 630 + gcm, _ := cipher.NewGCM(block) 631 + nonce := make([]byte, gcm.NonceSize()) 632 + io.ReadFull(rand.Reader, nonce) 633 + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) 634 + return base64.StdEncoding.EncodeToString(ciphertext) 635 + } 636 + 637 + func decrypt(ciphertext, key string) string { 638 + data, _ := base64.StdEncoding.DecodeString(ciphertext) 639 + block, _ := aes.NewCipher([]byte(key)) 640 + gcm, _ := cipher.NewGCM(block) 641 + nonceSize := gcm.NonceSize() 642 + nonce, cipherData := data[:nonceSize], data[nonceSize:] 643 + plaintext, _ := gcm.Open(nil, nonce, cipherData, nil) 644 + return string(plaintext) 645 + } 646 + 647 + func getUserFromSession(r *http.Request, db *sqlx.DB) User { 648 + session, _ := r.Cookie("session") 649 + if session == nil { 650 + return User{} 651 + } 652 + 653 + decrypted := decrypt(session.Value, *sessionKey) 654 + parts := strings.Split(decrypted, "|") 655 + if len(parts) != 2 { 656 + return User{} 657 + } 658 + 659 + userID := parts[0] 660 + var user User 661 + if err := db.Get(&user, "SELECT * FROM users WHERE id = $1", userID); err != nil { 662 + return User{} 663 + } 664 + 665 + return user 666 + } 667 + ``` 668 + 669 + --- 670 + 671 + ## Templ Components (Simplified) 672 + 673 + ### Dashboard Structure 674 + 675 + ```templ 676 + templ Dashboard(props DashboardProps) { 677 + @base("Sponsor Panel") { 678 + @Navbar(props.User.Login) 679 + <main class="max-w-4xl mx-auto px-4 py-8"> 680 + <h1 class="text-2xl font-bold mb-6">Welcome, @props.User.Login!</h1> 681 + 682 + <div class="grid md:grid-cols-2 gap-6"> 683 + @DiscordCard(props.DiscordInvite) 684 + @SponsorshipCard(props.User, props.IsSponsor, props.IsFiftyPlus) 685 + @TeamInviteCard(props.IsFiftyPlus) 686 + @LogoSubmitCard(props.IsSponsor) 687 + </div> 688 + </main> 689 + } 690 + } 691 + 692 + templ DiscordCard(inviteURL string) { 693 + <div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm"> 694 + <h2 class="font-semibold mb-2">Discord Community</h2> 695 + <p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> 696 + Join our Discord server to chat with other sponsors. 697 + </p> 698 + <a href={ inviteURL } target="_blank" class="inline-block bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg"> 699 + Join Discord 700 + </a> 701 + </div> 702 + } 703 + 704 + templ SponsorshipCard(user User, isSponsor, isFiftyPlus bool) { 705 + <div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm"> 706 + <h2 class="font-semibold mb-2">Your Sponsorship</h2> 707 + if !isSponsor { 708 + <p class="text-sm text-gray-600 dark:text-gray-400"> 709 + No active sponsorship found. 710 + </p> 711 + <a href="https://github.com/sponsors/Xe" target="_blank" class="inline-block bg-pink-600 hover:bg-pink-700 text-white px-4 py-2 rounded-lg mt-2"> 712 + Become a Sponsor 713 + </a> 714 + } else if isFiftyPlus { 715 + <p class="text-green-600 dark:text-green-400 font-semibold"> 716 + 💎 Premium Sponsor ($50+/month) 717 + </p> 718 + <p class="text-sm text-gray-600 dark:text-gray-400 mt-1"> 719 + Full access to all benefits! 720 + </p> 721 + } else { 722 + <p class="text-blue-600 dark:text-blue-400 font-semibold"> 723 + ❤️ Supporter ($1-49/month) 724 + </p> 725 + <p class="text-sm text-gray-600 dark:text-gray-400 mt-1"> 726 + Thank you for your support! 727 + </p> 728 + } 729 + </div> 730 + } 731 + 732 + templ TeamInviteCard(isFiftyPlus bool) { 733 + <div class={ "bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm", !isFiftyPlus: "opacity-50" }> 734 + <h2 class="font-semibold mb-2">Team Invitation</h2> 735 + if !isFiftyPlus { 736 + <p class="text-sm text-gray-600 dark:text-gray-400"> 737 + Requires $50+/month sponsorship. 738 + </p> 739 + } else { 740 + <form hx-post="/invite" hx-target="#invite-result" class="space-y-3"> 741 + <input type="text" name="username" placeholder="GitHub username" required 742 + class="w-full border rounded-lg px-3 py-2 dark:bg-gray-700 dark:border-gray-600" /> 743 + <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg"> 744 + Invite to Team 745 + </button> 746 + </form> 747 + <div id="invite-result"></div> 748 + } 749 + </div> 750 + } 751 + 752 + templ LogoSubmitCard(isSponsor bool) { 753 + <div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm"> 754 + <h2 class="font-semibold mb-2">Logo Submission</h2> 755 + <p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> 756 + Submit your company logo for the Anubis README. 757 + </p> 758 + if !isSponsor { 759 + <a href="https://github.com/sponsors/Xe" target="_blank" class="inline-block bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-lg"> 760 + Become a Sponsor 761 + </a> 762 + } else { 763 + <a href="/logo" class="inline-block bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-lg"> 764 + Submit Logo 765 + </a> 766 + } 767 + </div> 768 + } 769 + ``` 770 + 771 + ### Simple Logo Form 772 + 773 + ```templ 774 + templ LogoSubmitPage() { 775 + @base("Submit Logo") { 776 + @Navbar("") 777 + <main class="max-w-2xl mx-auto px-4 py-8"> 778 + <h1 class="text-2xl font-bold mb-6">Submit Your Logo</h1> 779 + 780 + <form hx-post="/logo" hx-encoding="multipart/form-data" hx-target="#result" class="space-y-4 bg-white dark:bg-gray-800 rounded-xl p-6"> 781 + <div> 782 + <label class="block text-sm font-medium mb-1">Company Name</label> 783 + <input type="text" name="company_name" required class="w-full border rounded-lg px-3 py-2 dark:bg-gray-700 dark:border-gray-600" /> 784 + </div> 785 + 786 + <div> 787 + <label class="block text-sm font-medium mb-1">Website</label> 788 + <input type="url" name="website" required class="w-full border rounded-lg px-3 py-2 dark:bg-gray-700 dark:border-gray-600" /> 789 + </div> 790 + 791 + <div> 792 + <label class="block text-sm font-medium mb-1">Logo (PNG, SVG, or JPG, max 5MB)</label> 793 + <input type="file" name="logo" accept="image/png,image/svg+xml,image/jpeg" required class="w-full border rounded-lg px-3 py-2 dark:bg-gray-700 dark:border-gray-600" /> 794 + </div> 795 + 796 + <button type="submit" class="bg-orange-600 hover:bg-orange-700 text-white px-6 py-2 rounded-lg"> 797 + Submit Logo 798 + </button> 799 + </form> 800 + 801 + <div id="result"></div> 802 + </main> 803 + } 804 + } 805 + ``` 806 + 807 + --- 808 + 809 + ## Error Handling (Simplified) 810 + 811 + ### Simple Error Responses 812 + 813 + ```go 814 + // Return HTML for HTMX or redirect for full page requests 815 + func handleError(w http.ResponseWriter, r *http.Request, message string, statusCode int) { 816 + if htmx.Is(r) { 817 + w.Header().Set("Content-Type", "text/html") 818 + w.WriteHeader(statusCode) 819 + components.ErrorMessage(message).Render(r.Context(), w) 820 + } else { 821 + w.Header().Set("Content-Type", "text/html") 822 + w.WriteHeader(statusCode) 823 + components.ErrorPage(message).Render(r.Context(), w) 824 + } 825 + } 826 + 827 + templ ErrorMessage(message string) { 828 + <div class="bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 p-4 rounded-lg"> 829 + { message } 830 + </div> 831 + } 832 + ``` 833 + 834 + --- 835 + 836 + ## Security Considerations 837 + 838 + | Concern | Mitigation | 839 + | ----------------- | ----------------------------------- | 840 + | CSRF on OAuth | State parameter in cookie | 841 + | Session hijacking | Secure, HttpOnly cookies | 842 + | Token storage | Encrypted at rest, in memory only | 843 + | SQL injection | Use sqlx with parameterized queries | 844 + | File uploads | Size limit (5MB), type validation | 845 + 846 + --- 847 + 848 + ## Deployment 849 + 850 + ### Environment 851 + 852 + ```yaml 853 + # Kubernetes deployment example 854 + apiVersion: v1 855 + kind: Secret 856 + metadata: 857 + name: sponsor-panel-secrets 858 + stringData: 859 + DATABASE_URL: "postgres://..." 860 + GITHUB_CLIENT_ID: "..." 861 + GITHUB_CLIENT_SECRET: "..." 862 + GITHUB_TOKEN: "..." 863 + SESSION_KEY: "32-byte-random-key" 864 + DISCORD_INVITE: "https://discord.gg/..." 865 + OAUTH_REDIRECT_URL: "https://sponsors.xeiaso.net/callback" 866 + --- 867 + apiVersion: apps/v1 868 + kind: Deployment 869 + metadata: 870 + name: sponsor-panel 871 + spec: 872 + replicas: 1 873 + template: 874 + spec: 875 + containers: 876 + - name: sponsor-panel 877 + image: ghcr.io/xeiaso/sponsor-panel:latest 878 + ports: 879 + - containerPort: 4823 880 + envFrom: 881 + - secretRef: 882 + name: sponsor-panel-secrets 883 + ``` 884 + 885 + --- 886 + 887 + ## Implementation Checklist (Simplified) 888 + 889 + ### Phase 1: Foundation 890 + 891 + - [ ] Create `internal/models/` with User and LogoSubmission structs 892 + - [ ] Set up sqlx with PostgreSQL 893 + - [ ] Create migration for 2 tables 894 + - [ ] Implement cookie encryption/decryption 895 + 896 + ### Phase 2: Authentication 897 + 898 + - [ ] Implement `/login` OAuth redirect 899 + - [ ] Implement `/callback` with user creation 900 + - [ ] Add session cookie handling 901 + - [ ] Create logout handler 902 + 903 + ### Phase 3: Sponsorship 904 + 905 + - [ ] Implement GraphQL client 906 + - [ ] Cache sponsorship data in user table 907 + - [ ] Add `IsSponsorAtTier()` helper method 908 + 909 + ### Phase 4: Dashboard & Features 910 + 911 + - [ ] Build Dashboard.templ with 4 cards 912 + - [ ] Implement `/invite` with GitHub API 913 + - [ ] Implement `/logo` with GitHub issue creation 914 + - [ ] Add error handling components 915 + 916 + ### Phase 5: Testing & Deploy 917 + 918 + - [ ] Unit tests for handlers 919 + - [ ] Integration tests with test database 920 + - [ ] Deploy to Kubernetes 921 + 922 + --- 923 + 924 + ## Migration from Original Spec 925 + 926 + ### What Changed 927 + 928 + | Aspect | Original | Simplified | Change | 929 + | ----------------- | --------------------------- | ------------------- | ------ | 930 + | Tables | 6 | 2 | -67% | 931 + | Dependencies | 15+ | 6 | -60% | 932 + | Endpoints | 10+ | 5 | -50% | 933 + | External services | 3 (GitHub, Tigris, Discord) | 2 (GitHub, Discord) | -33% | 934 + | Code estimate | ~4000 lines | ~1500 lines | -62% | 935 + 936 + ### Features Removed 937 + 938 + - Organization-specific teams 939 + - Invitation audit trail 940 + - Logo approval workflow 941 + - Image processing/resizing 942 + - Real-time validation 943 + - Activity feeds 944 + - Admin panel 945 + - Complex session management 946 + 947 + ### What's Still There 948 + 949 + - ✅ GitHub OAuth login 950 + - ✅ Sponsorship tier checking 951 + - ✅ Discord invite link 952 + - ✅ Team invitations ($50+) 953 + - ✅ Logo submission (all sponsors) 954 + 955 + --- 956 + 957 + ## Summary 958 + 959 + This simplified specification delivers the same core value with **60-70% less complexity**: 960 + 961 + - **2 tables** instead of 6 962 + - **5 endpoints** instead of 10+ 963 + - **No ORM** (use sqlx) 964 + - **No external storage** (use GitHub attachments) 965 + - **No image processing** (upload as-is) 966 + - **No audit trails** (fire and forget) 967 + - **Cookie-only sessions** (no database backing) 968 + 969 + Build the simplest thing that works. Add complexity only when you have proven need. 970 + 971 + --- 972 + 973 + **Simplified specification created:** 2026-02-07 974 + **Based on original spec:** `SPEC.md`, `UX_FLOWS.md` 975 + **Ruthless review by:** Cynical Engineer Agent
+522
cmd/sponsor-panel/docs/UX_FLOWS.md
··· 1 + # Sponsor Panel - Simplified UX Specification 2 + 3 + **Design philosophy:** Minimal UI for a minimal backend. No over-engineering. 4 + 5 + --- 6 + 7 + ## Overview 8 + 9 + This UX specification matches the simplified backend: 10 + - **2 tables** (users, logo_submissions) 11 + - **5 endpoints** (/login, /callback, /, /invite, /logo) 12 + - **No external storage** (GitHub attachments only) 13 + - **No approval workflows** 14 + - **No audit trails** 15 + 16 + The UI is simple, functional, and matches what the backend actually does. 17 + 18 + --- 19 + 20 + ## Pages (3 Total) 21 + 22 + ### 1. Login Page (`GET /`) 23 + 24 + Public page that redirects to GitHub OAuth. 25 + 26 + **Layout:** 27 + ```html 28 + <h1>Sponsor Panel</h1> 29 + <p>Manage your sponsorship benefits</p> 30 + <a href="/login" class="btn">Login with GitHub</a> 31 + 32 + <div class="benefits"> 33 + <h3>Benefits by tier:</h3> 34 + <ul> 35 + <li>✓ All sponsors: Discord access</li> 36 + <li>✓ $50+/month: Team invitations</li> 37 + <li>✓ All sponsors: Logo submission</li> 38 + </ul> 39 + </div> 40 + ``` 41 + 42 + **Behavior:** 43 + - If authenticated: redirect to `/` 44 + - If not authenticated: show login page 45 + 46 + --- 47 + 48 + ### 2. Dashboard (`GET /`) 49 + 50 + Main dashboard for authenticated sponsors. All sections on one page. 51 + 52 + **Layout:** 53 + ```html 54 + <nav> 55 + <img src="{avatar}" alt=""> 56 + <span>{username}</span> 57 + <a href="/logout">Logout</a> 58 + </nav> 59 + 60 + <main> 61 + <!-- Section 1: Discord (always visible) --> 62 + <section id="discord"> 63 + <h2>Discord Community</h2> 64 + <p>Join our Discord server to chat with other sponsors.</p> 65 + <a href="{discordInvite}" target="_blank" class="btn">Join Discord</a> 66 + </section> 67 + 68 + <!-- Section 2: Sponsorship Status --> 69 + <section id="sponsorship"> 70 + <h2>Your Sponsorship</h2> 71 + {if isSponsor} 72 + <p class="success">You sponsor at ${amount}/month - thank you!</p> 73 + {else} 74 + <p>Not an active sponsor.</p> 75 + <a href="https://github.com/sponsors/Xe" target="_blank">Become a Sponsor</a> 76 + {/if} 77 + </section> 78 + 79 + <!-- Section 3: Team Invitation ($50+ sponsors only) --> 80 + {if isFiftyPlus} 81 + <section id="invite"> 82 + <h2>Team Invitation</h2> 83 + <p>Invite team members to the TecharoHQ organization.</p> 84 + <form hx-post="/invite" hx-target="#invite-result"> 85 + <input type="text" name="username" placeholder="GitHub username" required> 86 + <button type="submit">Invite to Team</button> 87 + </form> 88 + <div id="invite-result"></div> 89 + </section> 90 + {/if} 91 + 92 + <!-- Section 4: Logo Submission (all sponsors) --> 93 + {if isSponsor} 94 + <section id="logo"> 95 + <h2>Logo Submission</h2> 96 + <p>Submit your company logo for the Anubis project README.</p> 97 + <form hx-post="/logo" hx-encoding="multipart/form-data" hx-target="#logo-result"> 98 + <input type="text" name="company" placeholder="Company Name" required> 99 + <input type="url" name="website" placeholder="Website URL" required> 100 + <input type="file" name="logo" accept="image/png,image/jpeg,image/svg+xml" required> 101 + <button type="submit">Submit Logo</button> 102 + </form> 103 + <div id="logo-result"></div> 104 + </section> 105 + {/if} 106 + </main> 107 + ``` 108 + 109 + **Behavior:** 110 + - If not authenticated: redirect to `/login` 111 + - If authenticated: render dashboard with sections based on sponsorship tier 112 + 113 + --- 114 + 115 + ### 3. OAuth Error (`/callback` with error) 116 + 117 + Simple error page when OAuth fails. 118 + 119 + **Layout:** 120 + ```html 121 + <h1>Authentication Failed</h1> 122 + <p>{errorMessage}</p> 123 + <a href="/login" class="btn">Try Again</a> 124 + ``` 125 + 126 + **Error messages:** 127 + - "Invalid OAuth state" - CSRF mismatch 128 + - "Failed to exchange token" - GitHub API error 129 + - "Failed to fetch user" - GitHub API error 130 + 131 + --- 132 + 133 + ## Templ Components (5 Total) 134 + 135 + ### base.templ 136 + 137 + Layout wrapper with head, styles, and body structure. 138 + 139 + ```templ 140 + templ base(title string, content templ.Component) { 141 + <!DOCTYPE html> 142 + <html lang="en"> 143 + <head> 144 + <meta charset="UTF-8"/> 145 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 146 + <title>{ title }</title> 147 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 148 + <script src="https://cdn.tailwindcss.com"></script> 149 + </head> 150 + <body class="bg-gray-50 dark:bg-gray-900"> 151 + @content 152 + </body> 153 + </html> 154 + } 155 + ``` 156 + 157 + ### Login.templ 158 + 159 + Login page with GitHub OAuth button. 160 + 161 + ```templ 162 + templ Login(discordInvite string) { 163 + @base("Sponsor Panel - Login") { 164 + <div class="min-h-screen flex items-center justify-center"> 165 + <div class="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg"> 166 + <h1 class="text-2xl font-bold mb-2">Sponsor Panel</h1> 167 + <p class="text-gray-600 dark:text-gray-400 mb-6">Manage your sponsorship benefits</p> 168 + 169 + <a href="/login" class="block w-full bg-gray-900 hover:bg-gray-800 text-white text-center py-3 rounded-lg mb-6"> 170 + Login with GitHub 171 + </a> 172 + 173 + <div class="border-t pt-4"> 174 + <h3 class="font-semibold mb-2">Benefits:</h3> 175 + <ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1"> 176 + <li>✓ All sponsors: Discord access</li> 177 + <li>✓ $50+/month: Team invitations</li> 178 + <li>✓ All sponsors: Logo submission</li> 179 + </ul> 180 + </div> 181 + </div> 182 + </div> 183 + } 184 + } 185 + ``` 186 + 187 + ### Dashboard.templ 188 + 189 + Main dashboard with conditional sections. 190 + 191 + ```templ 192 + templ Dashboard(props DashboardProps) { 193 + @base("Sponsor Panel - Dashboard") { 194 + @Navbar(props.User.Login, props.User.AvatarURL) 195 + 196 + <main class="max-w-4xl mx-auto px-4 py-8"> 197 + <h1 class="text-2xl font-bold mb-6">Welcome, @props.User.Login!</h1> 198 + 199 + <div class="grid md:grid-cols-2 gap-6"> 200 + @DiscordCard(props.DiscordInvite) 201 + @SponsorshipCard(props.IsSponsor, props.SponsorAmount, props.SponsorTier) 202 + </div> 203 + 204 + <div class="grid md:grid-cols-2 gap-6 mt-6"> 205 + if props.IsFiftyPlus { 206 + @TeamInviteCard() 207 + } 208 + if props.IsSponsor { 209 + @LogoSubmitCard() 210 + } 211 + </div> 212 + </main> 213 + } 214 + } 215 + 216 + templ Navbar(login, avatarURL string) { 217 + <nav class="bg-white dark:bg-gray-800 border-b px-4 py-3"> 218 + <div class="max-w-4xl mx-auto flex items-center justify-between"> 219 + <div class="flex items-center gap-3"> 220 + <img src={ avatarURL } class="w-8 h-8 rounded-full" alt=""> 221 + <span class="font-semibold">{ login }</span> 222 + </div> 223 + <a href="/logout" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"> 224 + Logout 225 + </a> 226 + </div> 227 + </nav> 228 + } 229 + 230 + templ DiscordCard(inviteURL string) { 231 + <div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm"> 232 + <h2 class="font-semibold mb-2">Discord Community</h2> 233 + <p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> 234 + Join our Discord server to chat with other sponsors. 235 + </p> 236 + <a href={ inviteURL } target="_blank" class="inline-block bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm"> 237 + Join Discord 238 + </a> 239 + </div> 240 + } 241 + 242 + templ SponsorshipCard(isSponsor bool, amount int, tier string) { 243 + <div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm"> 244 + <h2 class="font-semibold mb-2">Your Sponsorship</h2> 245 + if isSponsor { 246 + <p class="text-green-600 dark:text-green-400"> 247 + ${ fmt.Sprintf("%.2f", float64(amount) / 100) }/month - { tier } 248 + </p> 249 + <p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Thank you for your support!</p> 250 + } else { 251 + <p class="text-gray-600 dark:text-gray-400">Not an active sponsor.</p> 252 + <a href="https://github.com/sponsors/Xe" target="_blank" class="inline-block bg-pink-600 hover:bg-pink-700 text-white px-4 py-2 rounded-lg text-sm mt-2"> 253 + Become a Sponsor 254 + </a> 255 + } 256 + </div> 257 + } 258 + 259 + templ TeamInviteCard() { 260 + <div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm"> 261 + <h2 class="font-semibold mb-2">Team Invitation</h2> 262 + <p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> 263 + Invite team members to TecharoHQ. 264 + </p> 265 + <form hx-post="/invite" hx-target="#invite-result" class="space-y-3"> 266 + <input type="text" name="username" placeholder="GitHub username" required 267 + class="w-full border rounded-lg px-3 py-2 dark:bg-gray-700 dark:border-gray-600"> 268 + <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm w-full"> 269 + Invite to Team 270 + </button> 271 + </form> 272 + <div id="invite-result"></div> 273 + </div> 274 + } 275 + 276 + templ LogoSubmitCard() { 277 + <div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm"> 278 + <h2 class="font-semibold mb-2">Logo Submission</h2> 279 + <p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> 280 + Submit your company logo for the Anubis README. 281 + </p> 282 + <form hx-post="/logo" hx-encoding="multipart/form-data" hx-target="#logo-result" class="space-y-3"> 283 + <input type="text" name="company" placeholder="Company Name" required 284 + class="w-full border rounded-lg px-3 py-2 dark:bg-gray-700 dark:border-gray-600"> 285 + <input type="url" name="website" placeholder="Website URL" required 286 + class="w-full border rounded-lg px-3 py-2 dark:bg-gray-700 dark:border-gray-600"> 287 + <input type="file" name="logo" accept="image/png,image/jpeg,image/svg+xml" required 288 + class="w-full border rounded-lg px-3 py-2 dark:bg-gray-700 dark:border-gray-600"> 289 + <button type="submit" class="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-lg text-sm w-full"> 290 + Submit Logo 291 + </button> 292 + </form> 293 + <div id="logo-result"></div> 294 + </div> 295 + } 296 + ``` 297 + 298 + ### OAuthError.templ 299 + 300 + Error page for OAuth failures. 301 + 302 + ```templ 303 + templ OAuthError(message string) { 304 + @base("Authentication Error") { 305 + <div class="min-h-screen flex items-center justify-center"> 306 + <div class="max-w-md w-full bg-red-50 dark:bg-red-900/20 rounded-xl p-8 text-center"> 307 + <h1 class="text-xl font-bold text-red-900 dark:text-red-100 mb-2">Authentication Failed</h1> 308 + <p class="text-red-700 dark:text-red-300 mb-6">{ message }</p> 309 + <a href="/login" class="inline-block bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg"> 310 + Try Again 311 + </a> 312 + </div> 313 + </div> 314 + } 315 + } 316 + ``` 317 + 318 + ### FormResult.templ 319 + 320 + Generic success/error message for form submissions. 321 + 322 + ```templ 323 + templ FormResult(message string, isSuccess bool) { 324 + if isSuccess { 325 + <div class="bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 p-4 rounded-lg"> 326 + { message } 327 + </div> 328 + } else { 329 + <div class="bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 p-4 rounded-lg"> 330 + { message } 331 + </div> 332 + } 333 + } 334 + ``` 335 + 336 + --- 337 + 338 + ## HTMX Interactions (5 Patterns) 339 + 340 + ### 1. Login Redirect 341 + 342 + ``` 343 + User clicks "Login with GitHub" 344 + → GET /login 345 + → Redirect to GitHub OAuth 346 + ``` 347 + 348 + ### 2. OAuth Callback 349 + 350 + ``` 351 + GitHub redirects to /callback 352 + → Exchange code for token 353 + → Fetch user + sponsorship 354 + → Create session cookie 355 + → Redirect to / 356 + ``` 357 + 358 + ### 3. Dashboard Load 359 + 360 + ``` 361 + GET / (with session cookie) 362 + → Validate session 363 + → Fetch user from database 364 + → Render Dashboard.templ 365 + ``` 366 + 367 + ### 4. Team Invitation 368 + 369 + ``` 370 + User submits form (POST /invite) 371 + → Validate sponsorship ($50+) 372 + → Call GitHub Teams API 373 + → Return FormResult component 374 + → HTMX swaps #invite-result 375 + ``` 376 + 377 + ### 5. Logo Submission 378 + 379 + ``` 380 + User submits form (POST /logo) 381 + → Validate form fields 382 + → Create GitHub issue with attachment 383 + → Return FormResult component with issue link 384 + → HTMX swaps #logo-result 385 + ``` 386 + 387 + --- 388 + 389 + ## Error Handling (3 Patterns) 390 + 391 + ### Pattern 1: OAuth Error 392 + 393 + ```go 394 + // In callback handler 395 + if err != nil { 396 + components.OAuthError("Authentication failed: " + err.Error()).Render(r.Context(), w) 397 + return 398 + } 399 + ``` 400 + 401 + ### Pattern 2: Form Inline Error 402 + 403 + ```go 404 + // In invite/logo handler 405 + if err != nil { 406 + components.FormResult("Failed: " + err.Error(), false).Render(r.Context(), w) 407 + return 408 + } 409 + ``` 410 + 411 + ### Pattern 3: Not Authenticated 412 + 413 + ```go 414 + // In dashboard handler 415 + if session == nil { 416 + http.Redirect(w, r, "/login", http.StatusFound) 417 + return 418 + } 419 + ``` 420 + 421 + --- 422 + 423 + ## Success Responses (2 Patterns) 424 + 425 + ### Team Invitation Success 426 + 427 + ```templ 428 + templ FormResult("Invitation sent to @" + username + "!", true) 429 + ``` 430 + 431 + ### Logo Submission Success 432 + 433 + ```templ 434 + templ LogoSubmissionSuccess(companyName, issueURL string, issueNumber int) { 435 + <div class="bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 p-4 rounded-lg"> 436 + <p class="font-semibold">Logo submitted!</p> 437 + <p class="text-sm mt-1"> 438 + View issue: <a href={ issueURL } target="_blank" class="underline">#{ issueNumber }</a> 439 + </p> 440 + </div> 441 + } 442 + ``` 443 + 444 + --- 445 + 446 + ## State Management (2 States) 447 + 448 + ### 1. Authentication State 449 + 450 + ```go 451 + // Checked via session cookie 452 + session, _ := r.Cookie("session") 453 + if session == nil { 454 + // Not authenticated 455 + } 456 + ``` 457 + 458 + ### 2. Sponsorship State 459 + 460 + ```go 461 + // Fetched once at login, stored in database 462 + user.IsSponsorAtTier(5000) // $50+ 463 + user.IsSponsorAtTier(100) // $1+ 464 + ``` 465 + 466 + **No other state.** No loading spinners, no skeleton screens, no background polling. 467 + 468 + --- 469 + 470 + ## Responsive Design 471 + 472 + Use Tailwind's responsive classes: 473 + 474 + ```html 475 + <!-- Stack on mobile, grid on desktop --> 476 + <div class="grid md:grid-cols-2 gap-6"> 477 + <!-- Cards stack on mobile, 2 columns on desktop --> 478 + </div> 479 + ``` 480 + 481 + No custom breakpoints. No hamburger menus. Just Tailwind utilities. 482 + 483 + --- 484 + 485 + ## File Structure 486 + 487 + ``` 488 + cmd/sponsor-panel/ 489 + ├── main.go 490 + ├── templates/ 491 + │ ├── base.templ 492 + │ ├── Login.templ 493 + │ ├── Dashboard.templ 494 + │ ├── OAuthError.templ 495 + │ └── FormResult.templ 496 + └── static/ 497 + └── (none - using CDN for HTMX/Tailwind) 498 + ``` 499 + 500 + --- 501 + 502 + ## Summary 503 + 504 + **Original UX_FLOWS.md:** 505 + - 1,562 lines 506 + - 20+ components 507 + - 10+ HTMX endpoints 508 + - Complex state management 509 + - Multiple pages with nested flows 510 + 511 + **Simplified UX:** 512 + - 3 pages 513 + - 5 components 514 + - 5 endpoints (matching backend) 515 + - 2 states (auth + sponsorship) 516 + - Simple forms with inline feedback 517 + 518 + This matches the simplified backend: **minimal, functional, no over-engineering**. 519 + 520 + --- 521 + 522 + _Simplified UX specification, 2026-02-07_
+4
cmd/sponsor-panel/generate.go
··· 1 + package main 2 + 3 + //go:generate npx @tailwindcss/cli --input styles.css --output static/css/styles.css 4 + //go:generate go tool templ generate
+317
cmd/sponsor-panel/handlers.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "mime" 10 + "net/http" 11 + "path" 12 + "strings" 13 + 14 + "github.com/aws/aws-sdk-go-v2/service/s3" 15 + "github.com/google/go-github/v82/github" 16 + "github.com/google/uuid" 17 + 18 + "xeiaso.net/v4/cmd/sponsor-panel/templates" 19 + ) 20 + 21 + // inviteHandler handles POST /invite - invites a user to the GitHub team. 22 + func (s *Server) inviteHandler(w http.ResponseWriter, r *http.Request) { 23 + if r.Method != http.MethodPost { 24 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 25 + return 26 + } 27 + 28 + slog.Debug("inviteHandler: processing team invite request") 29 + 30 + // Get user from session 31 + user, err := s.getSessionUser(r) 32 + if err != nil { 33 + slog.Error("inviteHandler: failed to get session user", "err", err) 34 + renderError(w, "Authentication required", http.StatusUnauthorized) 35 + return 36 + } 37 + 38 + slog.Debug("inviteHandler: authenticated user", "user_id", user.ID, "login", user.Login) 39 + 40 + // Check $50+ sponsorship tier (5000 cents) 41 + if !user.IsSponsorAtTier(5000) { 42 + slog.Error("inviteHandler: user not eligible for team invitation", "user", user.Login, "user_id", user.ID) 43 + renderError(w, "Requires $50+/month sponsorship", http.StatusForbidden) 44 + return 45 + } 46 + 47 + slog.Debug("inviteHandler: user is eligible for team invitation", "user", user.Login, "user_id", user.ID) 48 + 49 + // Parse form 50 + if err := r.ParseForm(); err != nil { 51 + slog.Error("inviteHandler: failed to parse form", "err", err) 52 + renderError(w, "Invalid form data", http.StatusBadRequest) 53 + return 54 + } 55 + 56 + username := r.FormValue("username") 57 + if username == "" { 58 + slog.Error("inviteHandler: empty username provided", "user_id", user.ID) 59 + renderError(w, "Username required", http.StatusBadRequest) 60 + return 61 + } 62 + 63 + // Strip @ if present 64 + username = strings.TrimPrefix(username, "@") 65 + 66 + slog.Debug("inviteHandler: inviting user to team", "invited_by", user.Login, "username_to_invite", username) 67 + 68 + // Invite to team using go-github 69 + teamSlug := "botstopper-customers" 70 + org := "TecharoHQ" 71 + 72 + membership, _, err := s.ghClient.Teams.AddTeamMembershipBySlug( 73 + r.Context(), org, teamSlug, username, nil, 74 + ) 75 + 76 + if err != nil { 77 + slog.Error("inviteHandler: failed to invite to team", "user", username, "err", err, "invited_by", user.Login) 78 + // Check for common errors 79 + if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "422") { 80 + renderError(w, "User not found or already invited", http.StatusBadRequest) 81 + return 82 + } 83 + renderError(w, "Failed to invite: "+err.Error(), http.StatusInternalServerError) 84 + return 85 + } 86 + 87 + // Determine membership state 88 + state := "pending" 89 + if membership != nil && membership.State != nil && *membership.State == "active" { 90 + state = "active" 91 + } 92 + 93 + slog.Info("inviteHandler: team invitation successful", 94 + "invited_by", user.Login, 95 + "username_invited", username, 96 + "state", state, 97 + "team", teamSlug, 98 + "org", org) 99 + 100 + w.Header().Set("Content-Type", "text/html") 101 + w.WriteHeader(http.StatusOK) 102 + renderInviteSuccess(w, username, state) 103 + } 104 + 105 + // logoHandler handles POST /logo - submits a logo to a GitHub issue. 106 + func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) { 107 + if r.Method != http.MethodPost { 108 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 109 + return 110 + } 111 + 112 + slog.Debug("logoHandler: processing logo submission request") 113 + 114 + // Get user from session 115 + user, err := s.getSessionUser(r) 116 + if err != nil { 117 + slog.Error("logoHandler: failed to get session user", "err", err) 118 + renderError(w, "Authentication required", http.StatusUnauthorized) 119 + return 120 + } 121 + 122 + slog.Debug("logoHandler: authenticated user", "user_id", user.ID, "login", user.Login) 123 + 124 + // Check user is a sponsor (any tier) 125 + if !user.IsSponsorAtTier(100) { 126 + slog.Error("logoHandler: user not a sponsor", "user", user.Login, "user_id", user.ID) 127 + renderError(w, "Requires active sponsorship", http.StatusForbidden) 128 + return 129 + } 130 + 131 + slog.Debug("logoHandler: user is eligible for logo submission", "user", user.Login, "user_id", user.ID) 132 + 133 + // Parse multipart form (5MB max) 134 + if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { 135 + slog.Error("logoHandler: failed to parse multipart form", "err", err) 136 + renderError(w, "Invalid form data", http.StatusBadRequest) 137 + return 138 + } 139 + 140 + companyName := r.FormValue("company") 141 + website := r.FormValue("website") 142 + 143 + if companyName == "" || website == "" { 144 + slog.Error("logoHandler: missing required fields", "user_id", user.ID, "company", companyName, "website", website) 145 + renderError(w, "Company name and website are required", http.StatusBadRequest) 146 + return 147 + } 148 + 149 + // Get uploaded file 150 + file, header, err := r.FormFile("logo") 151 + if err != nil { 152 + slog.Error("logoHandler: failed to get logo file", "err", err) 153 + renderError(w, "Logo file required", http.StatusBadRequest) 154 + return 155 + } 156 + defer file.Close() 157 + 158 + slog.Debug("logoHandler: received logo file", 159 + "user_id", user.ID, 160 + "company", companyName, 161 + "filename", header.Filename, 162 + "size", header.Size) 163 + 164 + // Validate file size 165 + if header.Size > 5*1024*1024 { 166 + slog.Error("logoHandler: file too large", "user_id", user.ID, "size", header.Size) 167 + renderError(w, "File too large (max 5MB)", http.StatusBadRequest) 168 + return 169 + } 170 + 171 + // Read file into memory for S3 upload 172 + fileData, err := io.ReadAll(file) 173 + if err != nil { 174 + slog.Error("logoHandler: failed to read file", "err", err) 175 + renderError(w, "Failed to read file", http.StatusInternalServerError) 176 + return 177 + } 178 + 179 + // Upload to S3 with UUID v7 folder structure 180 + var logoURL string 181 + var s3Key string 182 + if s.bucketName != "" && s.s3Client != nil { 183 + // Generate UUID v7 for folder 184 + folderID := uuid.Must(uuid.NewV7()) 185 + ext := path.Ext(header.Filename) 186 + s3Key = fmt.Sprintf("%s/%s%s", folderID.String(), "logo", ext) 187 + 188 + // Detect content type 189 + contentType := mime.TypeByExtension(ext) 190 + if contentType == "" { 191 + contentType = "application/octet-stream" 192 + } 193 + 194 + slog.Debug("logoHandler: uploading to S3", 195 + "user_id", user.ID, 196 + "bucket", s.bucketName, 197 + "key", s3Key, 198 + "content_type", contentType) 199 + 200 + putInput := &s3.PutObjectInput{ 201 + Bucket: &s.bucketName, 202 + Key: &s3Key, 203 + Body: bytes.NewReader(fileData), 204 + ContentType: &contentType, 205 + } 206 + 207 + _, err := s.s3Client.PutObject(r.Context(), putInput) 208 + if err != nil { 209 + slog.Error("logoHandler: failed to upload to S3", "err", err, "user_id", user.ID) 210 + renderError(w, "Failed to upload logo: "+err.Error(), http.StatusInternalServerError) 211 + return 212 + } 213 + 214 + logoURL = fmt.Sprintf("https://%s.s3.amazonaws.com/%s", s.bucketName, s3Key) 215 + slog.Info("logoHandler: uploaded to S3", 216 + "user_id", user.ID, 217 + "url", logoURL, 218 + "key", s3Key) 219 + } 220 + 221 + // Create GitHub issue 222 + issueTitle := fmt.Sprintf("Logo Submission: %s", companyName) 223 + issueBody := fmt.Sprintf(`# Logo Submission: %s 224 + 225 + **Submitted by:** @%s 226 + 227 + ## Details 228 + 229 + - **Company:** %s 230 + - **Website:** %s 231 + - **File:** %s 232 + - **File Size:** %d bytes 233 + %s 234 + 235 + ## Next Steps 236 + 237 + 1. Review logo for quality and appropriateness 238 + 2. Add to Anubis README sponsors section 239 + 3. Close this issue 240 + 241 + /label logo-submission 242 + /label needs-review 243 + `, companyName, user.Login, companyName, website, header.Filename, header.Size, func() string { 244 + if logoURL != "" { 245 + return fmt.Sprintf("- **S3 Bucket:** %s\n- **S3 Key:** `%s`\n- **Logo URL:** %s", s.bucketName, s3Key, logoURL) 246 + } 247 + return "- **Storage:** Not configured (bucket-name not set)" 248 + }()) 249 + 250 + issue := &github.IssueRequest{ 251 + Title: github.Ptr(issueTitle), 252 + Body: github.Ptr(issueBody), 253 + Labels: &[]string{"logo-submission", "needs-review"}, 254 + } 255 + 256 + slog.Debug("logoHandler: creating GitHub issue", "user_id", user.ID, "company", companyName, "title", issueTitle) 257 + 258 + createdIssue, _, err := s.ghClient.Issues.Create(r.Context(), "TecharoHQ", *logoSubmissionRepo, issue) 259 + if err != nil { 260 + slog.Error("logoHandler: failed to create GitHub issue", "err", err, "user_id", user.ID, "company", companyName) 261 + renderError(w, "Failed to create issue: "+err.Error(), http.StatusInternalServerError) 262 + return 263 + } 264 + 265 + slog.Info("logoHandler: GitHub issue created successfully", 266 + "user_id", user.ID, 267 + "login", user.Login, 268 + "company", companyName, 269 + "issue_number", createdIssue.GetNumber(), 270 + "issue_url", createdIssue.GetHTMLURL()) 271 + 272 + // Store submission record 273 + submission := &LogoSubmission{ 274 + UserID: user.ID, 275 + CompanyName: companyName, 276 + Website: website, 277 + LogoURL: logoURL, 278 + GitHubIssueURL: createdIssue.GetHTMLURL(), 279 + GitHubIssueNumber: createdIssue.GetNumber(), 280 + } 281 + if err := createLogoSubmission(s.db, submission); err != nil { 282 + slog.Error("logoHandler: failed to store submission", "err", err, "user_id", user.ID, "issue_number", createdIssue.GetNumber()) 283 + } else { 284 + slog.Debug("logoHandler: submission stored in database", "user_id", user.ID, "issue_number", createdIssue.GetNumber()) 285 + } 286 + 287 + slog.Info("logoHandler: logo submission completed", 288 + "company", companyName, 289 + "issue", createdIssue.GetNumber(), 290 + "user", user.Login, 291 + "user_id", user.ID) 292 + 293 + w.Header().Set("Content-Type", "text/html") 294 + w.WriteHeader(http.StatusOK) 295 + renderLogoSuccess(w, companyName, createdIssue.GetHTMLURL(), createdIssue.GetNumber()) 296 + } 297 + 298 + // renderError renders an error message for HTMX. 299 + func renderError(w http.ResponseWriter, message string, statusCode int) { 300 + w.Header().Set("Content-Type", "text/html") 301 + w.WriteHeader(statusCode) 302 + templates.FormResult(message, false).Render(context.Background(), w) 303 + } 304 + 305 + // renderInviteSuccess renders success response for team invitation. 306 + func renderInviteSuccess(w http.ResponseWriter, username, state string) { 307 + stateText := "Invited" 308 + if state == "active" { 309 + stateText = "Active Member" 310 + } 311 + templates.InviteSuccess(username, stateText).Render(context.Background(), w) 312 + } 313 + 314 + // renderLogoSuccess renders success response for logo submission. 315 + func renderLogoSuccess(w http.ResponseWriter, company, issueURL string, issueNumber int) { 316 + templates.LogoSuccess(company, issueURL, issueNumber).Render(context.Background(), w) 317 + }
+283
cmd/sponsor-panel/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "database/sql" 7 + "embed" 8 + "encoding/base64" 9 + "flag" 10 + "fmt" 11 + "log" 12 + "log/slog" 13 + "net" 14 + "net/http" 15 + "os" 16 + "strings" 17 + 18 + "github.com/aws/aws-sdk-go-v2/config" 19 + "github.com/aws/aws-sdk-go-v2/service/s3" 20 + "github.com/facebookgo/flagenv" 21 + gh "github.com/google/go-github/v82/github" 22 + "github.com/gorilla/sessions" 23 + _ "github.com/jackc/pgx/v5/stdlib" 24 + _ "github.com/joho/godotenv/autoload" 25 + "github.com/prometheus/client_golang/prometheus/promhttp" 26 + "golang.org/x/oauth2" 27 + "golang.org/x/oauth2/github" 28 + "xeiaso.net/v4/internal" 29 + "xeiaso.net/v4/web/htmx" 30 + ) 31 + 32 + var ( 33 + bind = flag.String("bind", ":4823", "Port to listen on") 34 + databaseURL = flag.String("database-url", "", "Database URL") 35 + githubToken = flag.String("github-token", "", "GitHub token for operations") 36 + discordInvite = flag.String("discord-invite", "", "Discord invite link") 37 + fiftyPlusSpons = flag.String("fifty-plus-sponsors", "", "Comma-separated list of usernames/orgs that are always treated as $50+ sponsors") 38 + sessionKey = flag.String("session-key", "", "Session authentication/encryption key (32+ bytes for AES-256)") 39 + generateKey = flag.Bool("generate-session-key", false, "Generate a new session key and exit") 40 + cookieSecure = flag.Bool("cookie-secure", true, "Set Secure flag on cookies (enable for HTTPS)") 41 + bucketName = flag.String("bucket-name", "", "S3 bucket name for logo storage") 42 + logoSubmissionRepo = flag.String("logo-submission-repo", "anubis", "Repo to submit logo requests to") 43 + 44 + // OAuth configuration 45 + clientID = flag.String("github-client-id", "", "GitHub OAuth Client ID") 46 + clientSecret = flag.String("github-client-secret", "", "GitHub OAuth Client Secret") 47 + oauthRedirect = flag.String("oauth-redirect-url", "", "OAuth redirect URL") 48 + 49 + //go:embed static 50 + staticFS embed.FS 51 + ) 52 + 53 + // Server holds the application dependencies. 54 + type Server struct { 55 + db *sql.DB 56 + ghClient *gh.Client 57 + oauth *oauth2.Config 58 + discordInvite string 59 + fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors 60 + sessionStore *sessions.CookieStore 61 + cookieSecure bool 62 + bucketName string 63 + s3Client *s3.Client 64 + } 65 + 66 + func main() { 67 + flagenv.Parse() 68 + flag.Parse() 69 + internal.Slog() 70 + 71 + // Handle session key generation 72 + if *generateKey { 73 + key := make([]byte, 64) 74 + if _, err := rand.Read(key); err != nil { 75 + fmt.Fprintf(os.Stderr, "Failed to generate key: %v\n", err) 76 + os.Exit(1) 77 + } 78 + fmt.Println(base64.RawURLEncoding.EncodeToString(key)) 79 + os.Exit(0) 80 + } 81 + 82 + slog.Debug("main: starting sponsor panel service") 83 + 84 + ln, err := net.Listen("tcp", *bind) 85 + if err != nil { 86 + log.Fatal(err) 87 + } 88 + 89 + slog.Info("main: listening", "bind", *bind) 90 + 91 + // Required flags 92 + if *databaseURL == "" { 93 + slog.Error("database-url is required") 94 + os.Exit(1) 95 + } 96 + if *githubToken == "" { 97 + slog.Error("github-token is required") 98 + os.Exit(1) 99 + } 100 + if *discordInvite == "" { 101 + slog.Error("discord-invite is required") 102 + os.Exit(1) 103 + } 104 + 105 + // OAuth configuration 106 + if *clientID == "" { 107 + slog.Error("github-client-id is required") 108 + os.Exit(1) 109 + } 110 + if *clientSecret == "" { 111 + slog.Error("github-client-secret is required") 112 + os.Exit(1) 113 + } 114 + if *oauthRedirect == "" { 115 + slog.Error("oauth-redirect-url is required") 116 + os.Exit(1) 117 + } 118 + 119 + // Session key 120 + if *sessionKey == "" { 121 + key := make([]byte, 64) 122 + if _, err := rand.Read(key); err != nil { 123 + slog.Error("failed to generate session key", "err", err) 124 + os.Exit(1) 125 + } 126 + generatedKey := base64.RawURLEncoding.EncodeToString(key) 127 + slog.Error("session-key is required (should be 32+ bytes)") 128 + fmt.Fprintf(os.Stderr, "\nGenerate a key with:\n go run ./cmd/sponsor-panel --generate-session-key\n\nOr use this generated key:\n --session-key=%s\n", generatedKey) 129 + os.Exit(1) 130 + } 131 + if len(*sessionKey) < 32 { 132 + slog.Error("session-key must be at least 32 bytes for AES-256", "length", len(*sessionKey)) 133 + os.Exit(1) 134 + } 135 + 136 + // Connect to database 137 + slog.Debug("main: connecting to database") 138 + db, err := sql.Open("pgx", *databaseURL) 139 + if err != nil { 140 + slog.Error("failed to open database", "err", err) 141 + os.Exit(1) 142 + } 143 + defer db.Close() 144 + 145 + if err := db.Ping(); err != nil { 146 + slog.Error("failed to ping database", "err", err) 147 + os.Exit(1) 148 + } 149 + slog.Info("main: database connection established") 150 + 151 + // Run migrations 152 + slog.Debug("main: running migrations") 153 + if err := runMigrations(db); err != nil { 154 + slog.Error("failed to run migrations", "err", err) 155 + os.Exit(1) 156 + } 157 + slog.Info("main: migrations completed") 158 + 159 + // Create GitHub client 160 + slog.Debug("main: creating GitHub client") 161 + ghClient := gh.NewClient(nil).WithAuthToken(*githubToken) 162 + 163 + // OAuth configuration 164 + oauthConfig := &oauth2.Config{ 165 + ClientID: *clientID, 166 + ClientSecret: *clientSecret, 167 + RedirectURL: *oauthRedirect, 168 + Scopes: []string{"read:user", "user:email", "read:org", "read:sponsors"}, 169 + Endpoint: github.Endpoint, 170 + } 171 + slog.Debug("main: OAuth configured", "client_id", *clientID, "redirect_url", *oauthRedirect) 172 + 173 + // Parse fifty-plus sponsors list 174 + fiftyPlusMap := make(map[string]bool) 175 + if *fiftyPlusSpons != "" { 176 + slog.Debug("main: parsing fifty-plus sponsors", "list", *fiftyPlusSpons) 177 + for _, sponsor := range strings.Split(*fiftyPlusSpons, ",") { 178 + sponsor = strings.TrimSpace(sponsor) 179 + if sponsor != "" { 180 + fiftyPlusMap[sponsor] = true 181 + } 182 + } 183 + slog.Info("main: loaded fifty-plus sponsors", "count", len(fiftyPlusMap)) 184 + } 185 + 186 + // Create session store 187 + slog.Debug("main: creating session store") 188 + sessionStore := sessions.NewCookieStore([]byte(*sessionKey)) 189 + sessionStore.Options = &sessions.Options{ 190 + Path: "/", 191 + MaxAge: 30 * 24 * 3600, // 30 days 192 + HttpOnly: true, 193 + SameSite: http.SameSiteLaxMode, 194 + Secure: *cookieSecure, 195 + } 196 + 197 + // Create S3 client for logo storage 198 + var s3Client *s3.Client 199 + if *bucketName != "" { 200 + slog.Debug("main: creating S3 client", "bucket", *bucketName) 201 + cfg, err := config.LoadDefaultConfig(context.Background()) 202 + if err != nil { 203 + slog.Error("main: failed to load AWS config", "err", err) 204 + os.Exit(1) 205 + } 206 + s3Client = s3.NewFromConfig(cfg) 207 + slog.Info("main: S3 client created", "bucket", *bucketName) 208 + } 209 + 210 + server := &Server{ 211 + db: db, 212 + ghClient: ghClient, 213 + oauth: oauthConfig, 214 + discordInvite: *discordInvite, 215 + fiftyPlusSponsors: fiftyPlusMap, 216 + sessionStore: sessionStore, 217 + cookieSecure: *cookieSecure, 218 + bucketName: *bucketName, 219 + s3Client: s3Client, 220 + } 221 + 222 + mux := http.NewServeMux() 223 + 224 + htmx.Mount(mux) 225 + mux.Handle("/static/", http.FileServer(http.FS(staticFS))) 226 + mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { 227 + http.ServeFileFS(w, r, staticFS, "static/favicon.ico") 228 + }) 229 + 230 + // Health check 231 + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 232 + w.Header().Set("Content-Type", "application/json") 233 + w.WriteHeader(http.StatusOK) 234 + w.Write([]byte(`{"status":"ok","service":"sponsor-panel"}`)) 235 + }) 236 + 237 + // OAuth handlers 238 + mux.HandleFunc("/login", server.loginHandler) 239 + mux.HandleFunc("/callback", server.callbackHandler) 240 + mux.HandleFunc("/logout", server.logoutHandler) 241 + 242 + // Login page handler 243 + mux.HandleFunc("/login-page", server.loginPageHandler) 244 + 245 + // Dashboard handler (also serves login page if not authenticated) 246 + mux.HandleFunc("/", server.dashboardHandler) 247 + 248 + // Feature handlers 249 + mux.HandleFunc("/invite", server.inviteHandler) 250 + mux.HandleFunc("/logo", server.logoHandler) 251 + 252 + // Expose Prometheus metrics at /metrics for observability 253 + mux.Handle("/metrics", promhttp.Handler()) 254 + 255 + slog.Debug("main: HTTP routes registered", 256 + "routes", []string{ 257 + "/health", 258 + "/login", 259 + "/callback", 260 + "/logout", 261 + "/login-page", 262 + "/", 263 + "/invite", 264 + "/logo", 265 + "/metrics", 266 + }) 267 + 268 + var h http.Handler = mux 269 + h = internal.AcceptEncodingMiddleware(h) 270 + h = internal.RefererMiddleware(h) 271 + 272 + slog.Info( 273 + "Sponsor panel service ready", 274 + "bind", *bind, 275 + "has-database-url", *databaseURL != "", 276 + "has-github-token", *githubToken != "", 277 + "discord-invite", *discordInvite, 278 + "github-client-id", *clientID, 279 + "has-github-client-secret", *clientSecret != "", 280 + "oauth-redirect-url", *oauthRedirect, 281 + ) 282 + log.Fatal(http.Serve(ln, h)) 283 + }
+56
cmd/sponsor-panel/migrations.go
··· 1 + package main 2 + 3 + import ( 4 + "database/sql" 5 + "log/slog" 6 + ) 7 + 8 + const migrationSchema = ` 9 + -- Users table: GitHub accounts + sponsorship data 10 + CREATE TABLE IF NOT EXISTS users ( 11 + id SERIAL PRIMARY KEY, 12 + github_id BIGINT UNIQUE NOT NULL, 13 + login TEXT NOT NULL UNIQUE, 14 + avatar_url TEXT, 15 + name TEXT, 16 + email TEXT, 17 + 18 + -- Sponsorship data from GraphQL (cached) 19 + sponsorship_data JSONB, 20 + last_sponsorship_check TIMESTAMP DEFAULT NOW(), 21 + 22 + -- Timestamps 23 + created_at TIMESTAMP DEFAULT NOW(), 24 + updated_at TIMESTAMP DEFAULT NOW() 25 + ); 26 + 27 + -- Logo submissions: Simple tracking only 28 + CREATE TABLE IF NOT EXISTS logo_submissions ( 29 + id SERIAL PRIMARY KEY, 30 + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, 31 + 32 + company_name TEXT NOT NULL, 33 + website TEXT NOT NULL, 34 + logo_url TEXT, 35 + github_issue_url TEXT, 36 + github_issue_number INTEGER, 37 + 38 + submitted_at TIMESTAMP DEFAULT NOW() 39 + ); 40 + 41 + -- Indexes for common queries 42 + CREATE INDEX IF NOT EXISTS idx_users_github_id ON users(github_id); 43 + CREATE INDEX IF NOT EXISTS idx_users_login ON users(login); 44 + CREATE INDEX IF NOT EXISTS idx_logo_user_id ON logo_submissions(user_id); 45 + ` 46 + 47 + // runMigrations executes the database schema migration. 48 + func runMigrations(db *sql.DB) error { 49 + slog.Info("running database migrations") 50 + _, err := db.Exec(migrationSchema) 51 + if err != nil { 52 + return err 53 + } 54 + slog.Info("database migrations completed") 55 + return nil 56 + }
+184
cmd/sponsor-panel/models.go
··· 1 + package main 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "log/slog" 7 + "time" 8 + ) 9 + 10 + // User represents a GitHub user with cached sponsorship data. 11 + // This is the simplified model from SPEC.md using sqlx (not GORM). 12 + type User struct { 13 + ID int `json:"id" db:"id"` 14 + GitHubID int64 `json:"github_id" db:"github_id"` 15 + Login string `json:"login" db:"login"` 16 + AvatarURL string `json:"avatar_url" db:"avatar_url"` 17 + Name string `json:"name" db:"name"` 18 + Email string `json:"email" db:"email"` 19 + SponsorshipData string `json:"-" db:"sponsorship_data"` // JSON blob 20 + LastSponsorshipCheck time.Time `json:"last_sponsorship_check" db:"last_sponsorship_check"` 21 + CreatedAt time.Time `json:"created_at" db:"created_at"` 22 + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 23 + } 24 + 25 + // SponsorshipData represents the cached GraphQL response. 26 + type SponsorshipData struct { 27 + IsActive bool `json:"is_active"` 28 + MonthlyAmount int `json:"monthly_amount_cents"` 29 + TierName string `json:"tier_name"` 30 + PrivacyLevel string `json:"privacy_level"` 31 + } 32 + 33 + // IsSponsorAtTier returns true if user sponsors at or above the given amount (in cents). 34 + func (u *User) IsSponsorAtTier(minCents int) bool { 35 + if u.SponsorshipData == "" { 36 + return false 37 + } 38 + 39 + var data SponsorshipData 40 + if err := json.Unmarshal([]byte(u.SponsorshipData), &data); err != nil { 41 + slog.Error("IsSponsorAtTier: failed to parse sponsorship data", "user_id", u.ID, "err", err, "raw_data", u.SponsorshipData) 42 + return false 43 + } 44 + 45 + result := data.IsActive && data.MonthlyAmount >= minCents 46 + slog.Debug("IsSponsorAtTier: tier check", 47 + "user_id", u.ID, 48 + "login", u.Login, 49 + "min_cents", minCents, 50 + "actual_cents", data.MonthlyAmount, 51 + "is_active", data.IsActive, 52 + "result", result) 53 + 54 + return result 55 + } 56 + 57 + // LogoSubmission represents a logo submission. 58 + type LogoSubmission struct { 59 + ID int `json:"id" db:"id"` 60 + UserID int `json:"user_id" db:"user_id"` 61 + CompanyName string `json:"company_name" db:"company_name"` 62 + Website string `json:"website" db:"website"` 63 + LogoURL string `json:"logo_url" db:"logo_url"` 64 + GitHubIssueURL string `json:"github_issue_url" db:"github_issue_url"` 65 + GitHubIssueNumber int `json:"github_issue_number" db:"github_issue_number"` 66 + SubmittedAt time.Time `json:"submitted_at" db:"submitted_at"` 67 + } 68 + 69 + // getUserByID retrieves a user by ID from the database. 70 + func getUserByID(db *sql.DB, userID int) (*User, error) { 71 + slog.Debug("getUserByID: querying user", "user_id", userID) 72 + 73 + var user User 74 + err := db.QueryRow(` 75 + SELECT id, github_id, login, avatar_url, name, email, 76 + sponsorship_data, last_sponsorship_check, created_at, updated_at 77 + FROM users WHERE id = $1 78 + `, userID).Scan( 79 + &user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 80 + &user.Name, &user.Email, &user.SponsorshipData, 81 + &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 82 + ) 83 + if err != nil { 84 + slog.Error("getUserByID: user not found", "user_id", userID, "err", err) 85 + return nil, err 86 + } 87 + 88 + slog.Debug("getUserByID: user found", "user_id", userID, "login", user.Login) 89 + return &user, nil 90 + } 91 + 92 + // getUserByGitHubID retrieves a user by GitHub ID from the database. 93 + func getUserByGitHubID(db *sql.DB, githubID int64) (*User, error) { 94 + var user User 95 + err := db.QueryRow(` 96 + SELECT id, github_id, login, avatar_url, name, email, 97 + sponsorship_data, last_sponsorship_check, created_at, updated_at 98 + FROM users WHERE github_id = $1 99 + `, githubID).Scan( 100 + &user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 101 + &user.Name, &user.Email, &user.SponsorshipData, 102 + &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 103 + ) 104 + if err != nil { 105 + return nil, err 106 + } 107 + return &user, nil 108 + } 109 + 110 + // upsertUser creates or updates a user in the database. 111 + func upsertUser(db *sql.DB, user *User) error { 112 + slog.Debug("upsertUser: attempting upsert", "github_id", user.GitHubID, "login", user.Login) 113 + 114 + // Try update first 115 + result, err := db.Exec(` 116 + UPDATE users 117 + SET login=$1, avatar_url=$2, name=$3, email=$4, 118 + sponsorship_data=$5, last_sponsorship_check=NOW(), updated_at=NOW() 119 + WHERE github_id=$6 120 + `, user.Login, user.AvatarURL, user.Name, user.Email, 121 + user.SponsorshipData, user.GitHubID) 122 + if err != nil { 123 + slog.Error("upsertUser: update failed", "err", err, "github_id", user.GitHubID) 124 + return err 125 + } 126 + 127 + rows, _ := result.RowsAffected() 128 + if rows > 0 { 129 + slog.Debug("upsertUser: updated existing user", "github_id", user.GitHubID, "rows_affected", rows) 130 + // Fetch the updated user 131 + return db.QueryRow(` 132 + SELECT id, github_id, login, avatar_url, name, email, 133 + sponsorship_data, last_sponsorship_check, created_at, updated_at 134 + FROM users WHERE github_id = $1 135 + `, user.GitHubID).Scan( 136 + &user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 137 + &user.Name, &user.Email, &user.SponsorshipData, 138 + &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 139 + ) 140 + } 141 + 142 + slog.Debug("upsertUser: inserting new user", "github_id", user.GitHubID, "login", user.Login) 143 + 144 + // Insert new user 145 + return db.QueryRow(` 146 + INSERT INTO users (github_id, login, avatar_url, name, email, sponsorship_data) 147 + VALUES ($1, $2, $3, $4, $5, $6) 148 + RETURNING id, github_id, login, avatar_url, name, email, 149 + sponsorship_data, last_sponsorship_check, created_at, updated_at 150 + `, user.GitHubID, user.Login, user.AvatarURL, user.Name, user.Email, 151 + user.SponsorshipData).Scan( 152 + &user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 153 + &user.Name, &user.Email, &user.SponsorshipData, 154 + &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 155 + ) 156 + } 157 + 158 + // createLogoSubmission creates a logo submission in the database. 159 + func createLogoSubmission(db *sql.DB, submission *LogoSubmission) error { 160 + slog.Debug("createLogoSubmission: inserting submission", 161 + "user_id", submission.UserID, 162 + "company", submission.CompanyName, 163 + "issue_number", submission.GitHubIssueNumber) 164 + 165 + err := db.QueryRow(` 166 + INSERT INTO logo_submissions (user_id, company_name, website, logo_url, github_issue_url, github_issue_number) 167 + VALUES ($1, $2, $3, $4, $5, $6) 168 + RETURNING id, submitted_at 169 + `, submission.UserID, submission.CompanyName, submission.Website, 170 + submission.LogoURL, submission.GitHubIssueURL, submission.GitHubIssueNumber, 171 + ).Scan(&submission.ID, &submission.SubmittedAt) 172 + 173 + if err != nil { 174 + slog.Error("createLogoSubmission: failed to insert", "err", err, "user_id", submission.UserID) 175 + return err 176 + } 177 + 178 + slog.Debug("createLogoSubmission: submission inserted", 179 + "user_id", submission.UserID, 180 + "submission_id", submission.ID, 181 + "submitted_at", submission.SubmittedAt) 182 + 183 + return nil 184 + }
+638
cmd/sponsor-panel/oauth.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "io" 10 + "log/slog" 11 + "net/http" 12 + "strings" 13 + 14 + "github.com/gorilla/sessions" 15 + "xeiaso.net/v4/cmd/sponsor-panel/templates" 16 + ) 17 + 18 + // generateState generates a random OAuth state parameter. 19 + func generateState() (string, error) { 20 + b := make([]byte, 16) 21 + if _, err := rand.Read(b); err != nil { 22 + return "", err 23 + } 24 + return base64.URLEncoding.EncodeToString(b), nil 25 + } 26 + 27 + // loginHandler initiates the GitHub OAuth flow. 28 + func (s *Server) loginHandler(w http.ResponseWriter, r *http.Request) { 29 + if r.Method != http.MethodGet { 30 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 31 + return 32 + } 33 + 34 + slog.Debug("loginHandler: initiating GitHub OAuth flow") 35 + 36 + state, err := generateState() 37 + if err != nil { 38 + slog.Error("failed to generate state", "err", err) 39 + http.Error(w, "Failed to generate state", http.StatusInternalServerError) 40 + return 41 + } 42 + 43 + slog.Debug("loginHandler: generated OAuth state", "state", state[:8]+"...") // Log prefix only for security 44 + 45 + // Set state in cookie for CSRF protection 46 + http.SetCookie(w, &http.Cookie{ 47 + Name: "oauth_state", 48 + Value: state, 49 + Path: "/", 50 + HttpOnly: true, 51 + SameSite: http.SameSiteLaxMode, 52 + Secure: s.cookieSecure, 53 + }) 54 + 55 + // Redirect to GitHub 56 + url := s.oauth.AuthCodeURL(state) 57 + slog.Debug("loginHandler: redirecting to GitHub OAuth", "url", url) 58 + http.Redirect(w, r, url, http.StatusFound) 59 + } 60 + 61 + // githubUser represents the GitHub user API response. 62 + type githubUser struct { 63 + ID int64 `json:"id"` 64 + Login string `json:"login"` 65 + AvatarURL string `json:"avatar_url"` 66 + Name string `json:"name"` 67 + Email string `json:"email"` 68 + } 69 + 70 + // fetchGitHubUser fetches the user from GitHub using the access token. 71 + func fetchGitHubUser(ctx context.Context, token string) (*githubUser, error) { 72 + slog.Debug("fetchGitHubUser: fetching user from GitHub API") 73 + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil) 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + tokenPrefix := token[:8] + "..." 79 + req.Header.Set("Authorization", "Bearer "+token) 80 + req.Header.Set("Accept", "application/json") 81 + 82 + slog.Debug("fetchGitHubUser: sending request to GitHub", "token_prefix", tokenPrefix) 83 + 84 + resp, err := http.DefaultClient.Do(req) 85 + if err != nil { 86 + slog.Error("fetchGitHubUser: request failed", "err", err) 87 + return nil, err 88 + } 89 + defer resp.Body.Close() 90 + 91 + slog.Debug("fetchGitHubUser: received response", "status", resp.StatusCode) 92 + 93 + if resp.StatusCode != http.StatusOK { 94 + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) 95 + } 96 + 97 + var user githubUser 98 + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { 99 + slog.Error("fetchGitHubUser: failed to decode response", "err", err) 100 + return nil, err 101 + } 102 + 103 + slog.Debug("fetchGitHubUser: successfully fetched user", 104 + "github_id", user.ID, 105 + "login", user.Login, 106 + "name", user.Name, 107 + "email", user.Email) 108 + 109 + return &user, nil 110 + } 111 + 112 + // sponsorshipInfo represents the sponsorship tier info returned from GraphQL. 113 + type sponsorshipInfo struct { 114 + Tier struct { 115 + MonthlyPriceInCents int `json:"monthlyPriceInCents"` 116 + Name string `json:"name"` 117 + } `json:"tier"` 118 + PrivacyLevel string `json:"privacyLevel"` 119 + IsActive bool `json:"isActive"` 120 + CreatedAt string `json:"createdAt"` 121 + } 122 + 123 + // organizationSponsorship represents an organization with its sponsorship status. 124 + type organizationSponsorship struct { 125 + Login string `json:"login"` 126 + Name string `json:"name"` 127 + SponsorshipForViewer *sponsorshipInfo `json:"sponsorshipForViewer"` 128 + } 129 + 130 + // graphqlUserOrganizationsResponse represents the GraphQL response for user organizations with sponsorship info. 131 + type graphqlUserOrganizationsResponse struct { 132 + Data struct { 133 + User struct { 134 + Login string `json:"login"` 135 + Organizations struct { 136 + Nodes []organizationSponsorship `json:"nodes"` 137 + } `json:"organizations"` 138 + } `json:"user"` 139 + } `json:"data"` 140 + } 141 + 142 + // fetchUserOrganizations fetches the list of organizations the user belongs to via REST API. 143 + func fetchUserOrganizations(ctx context.Context, token string) (map[string]bool, error) { 144 + slog.Debug("fetchUserOrganizations: fetching user organizations") 145 + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/orgs", nil) 146 + if err != nil { 147 + return nil, err 148 + } 149 + 150 + req.Header.Set("Authorization", "Bearer "+token) 151 + req.Header.Set("Accept", "application/json") 152 + 153 + resp, err := http.DefaultClient.Do(req) 154 + if err != nil { 155 + return nil, err 156 + } 157 + defer resp.Body.Close() 158 + 159 + if resp.StatusCode != http.StatusOK { 160 + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) 161 + } 162 + 163 + var orgs []struct { 164 + Login string `json:"login"` 165 + } 166 + if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil { 167 + return nil, err 168 + } 169 + 170 + orgMap := make(map[string]bool) 171 + for _, org := range orgs { 172 + orgMap[org.Login] = true 173 + } 174 + 175 + slog.Debug("fetchUserOrganizations: found organizations", "count", len(orgMap), "orgs", orgMap) 176 + return orgMap, nil 177 + } 178 + 179 + // fetchUserOrganizationsWithSponsorship fetches the user's organizations with their sponsorship status via GraphQL. 180 + // This implements the query from the implementation guide. 181 + func fetchUserOrganizationsWithSponsorship(ctx context.Context, token string, userLogin string) (map[string]*sponsorshipInfo, error) { 182 + slog.Debug("fetchUserOrganizationsWithSponsorship: fetching user organizations with sponsorship info", "user", userLogin) 183 + 184 + // Build the request body as a map and marshal to JSON 185 + reqBody := map[string]any{ 186 + "query": `query ($userLogin: String!) { user(login: $userLogin) { organizations(first: 20) { nodes { login name sponsorshipForViewer { isActive tier { name monthlyPriceInCents } } } } } }`, 187 + "variables": map[string]string{"userLogin": userLogin}, 188 + } 189 + 190 + bodyBytes, err := json.Marshal(reqBody) 191 + if err != nil { 192 + return nil, err 193 + } 194 + 195 + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.github.com/graphql", strings.NewReader(string(bodyBytes))) 196 + if err != nil { 197 + return nil, err 198 + } 199 + 200 + req.Header.Set("Authorization", "Bearer "+token) 201 + req.Header.Set("Content-Type", "application/json") 202 + 203 + resp, err := http.DefaultClient.Do(req) 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer resp.Body.Close() 208 + 209 + if resp.StatusCode != http.StatusOK { 210 + body, _ := io.ReadAll(resp.Body) 211 + return nil, fmt.Errorf("GraphQL API returned status %d: %s", resp.StatusCode, string(body)) 212 + } 213 + 214 + var result graphqlUserOrganizationsResponse 215 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 216 + return nil, err 217 + } 218 + 219 + orgMap := make(map[string]*sponsorshipInfo) 220 + for _, org := range result.Data.User.Organizations.Nodes { 221 + sponsorship := org.SponsorshipForViewer 222 + if sponsorship != nil && sponsorship.IsActive { 223 + slog.Debug("fetchUserOrganizationsWithSponsorship: found sponsoring org", 224 + "org", org.Login, 225 + "tier_name", sponsorship.Tier.Name, 226 + "monthly_amount_cents", sponsorship.Tier.MonthlyPriceInCents) 227 + } 228 + orgMap[org.Login] = sponsorship 229 + } 230 + 231 + slog.Debug("fetchUserOrganizationsWithSponsorship: found organizations", "count", len(orgMap)) 232 + return orgMap, nil 233 + } 234 + 235 + // fetchSponsorshipForEntity checks if a specific entity (user or org) sponsors the viewer. 236 + // Returns the sponsorship tier info if active, nil otherwise. 237 + func fetchSponsorshipForEntity(ctx context.Context, token string, entityType string, entityLogin string) (*sponsorshipInfo, error) { 238 + // For checking if someone sponsors the viewer, we need to use viewer.sponsorshipsAsSponsor 239 + // and filter by the entity login 240 + var queryStr string 241 + if entityType == "user" { 242 + queryStr = `query { 243 + viewer { 244 + sponsorshipsAsSponsor(first: 100) { 245 + nodes { 246 + sponsorEntity { 247 + ... on User { 248 + login 249 + } 250 + } 251 + tier { 252 + monthlyPriceInCents 253 + name 254 + } 255 + isActive 256 + } 257 + } 258 + } 259 + }` 260 + } else { 261 + queryStr = `query { 262 + viewer { 263 + sponsorshipsAsSponsor(first: 100) { 264 + nodes { 265 + sponsorEntity { 266 + ... on Organization { 267 + login 268 + } 269 + } 270 + tier { 271 + monthlyPriceInCents 272 + name 273 + } 274 + isActive 275 + } 276 + } 277 + } 278 + }` 279 + } 280 + 281 + // Build the request body as a map and marshal to JSON 282 + reqBody := map[string]any{ 283 + "query": queryStr, 284 + } 285 + 286 + bodyBytes, err := json.Marshal(reqBody) 287 + if err != nil { 288 + return nil, err 289 + } 290 + 291 + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.github.com/graphql", strings.NewReader(string(bodyBytes))) 292 + if err != nil { 293 + return nil, err 294 + } 295 + 296 + req.Header.Set("Authorization", "Bearer "+token) 297 + req.Header.Set("Content-Type", "application/json") 298 + 299 + resp, err := http.DefaultClient.Do(req) 300 + if err != nil { 301 + return nil, err 302 + } 303 + defer resp.Body.Close() 304 + 305 + if resp.StatusCode != http.StatusOK { 306 + body, _ := io.ReadAll(resp.Body) 307 + return nil, fmt.Errorf("GraphQL API returned status %d: %s", resp.StatusCode, string(body)) 308 + } 309 + 310 + responseBody, err := io.ReadAll(resp.Body) 311 + if err != nil { 312 + return nil, err 313 + } 314 + 315 + // Parse the response - need a new response type for viewer.sponsorshipsAsSponsor 316 + type viewerSponsorshipsResponse struct { 317 + Data struct { 318 + Viewer struct { 319 + SponsorshipsAsSponsor struct { 320 + Nodes []struct { 321 + SponsorEntity struct { 322 + Login string `json:"login"` 323 + } `json:"sponsorEntity"` 324 + Tier struct { 325 + MonthlyPriceInCents int `json:"monthlyPriceInCents"` 326 + Name string `json:"name"` 327 + } `json:"tier"` 328 + IsActive bool `json:"isActive"` 329 + } `json:"nodes"` 330 + } `json:"sponsorshipsAsSponsor"` 331 + } `json:"viewer"` 332 + } `json:"data"` 333 + } 334 + 335 + var result viewerSponsorshipsResponse 336 + if err := json.Unmarshal(responseBody, &result); err != nil { 337 + return nil, err 338 + } 339 + 340 + // Find the matching sponsor 341 + for _, sponsorship := range result.Data.Viewer.SponsorshipsAsSponsor.Nodes { 342 + if sponsorship.SponsorEntity.Login == entityLogin && sponsorship.IsActive { 343 + slog.Debug("fetchSponsorshipForEntity: found active sponsorship", 344 + "entity", entityLogin, 345 + "tier_name", sponsorship.Tier.Name, 346 + "monthly_amount_cents", sponsorship.Tier.MonthlyPriceInCents) 347 + return &sponsorshipInfo{ 348 + Tier: struct { 349 + MonthlyPriceInCents int `json:"monthlyPriceInCents"` 350 + Name string `json:"name"` 351 + }{ 352 + MonthlyPriceInCents: sponsorship.Tier.MonthlyPriceInCents, 353 + Name: sponsorship.Tier.Name, 354 + }, 355 + IsActive: sponsorship.IsActive, 356 + }, nil 357 + } 358 + } 359 + 360 + slog.Debug("fetchSponsorshipForEntity: no active sponsorship found", 361 + "entity_type", entityType, 362 + "entity_login", entityLogin) 363 + return nil, nil 364 + } 365 + 366 + // fetchSponsorship fetches sponsorship data from GitHub GraphQL API. 367 + // It checks the explicit allowlist first, then direct user sponsorship, then organizational membership. 368 + func fetchSponsorship(ctx context.Context, token string, userLogin string, userOrgs map[string]bool, userOrgsWithSponsorship map[string]*sponsorshipInfo, fiftyPlusSponsors map[string]bool) (string, error) { 369 + slog.Debug("fetchSponsorship: checking sponsorship", "user", userLogin) 370 + 371 + // Check if user is in the fifty-plus sponsors list first (highest priority) 372 + if fiftyPlusSponsors[userLogin] { 373 + slog.Info("fetchSponsorship: user in fifty-plus sponsors list", "user", userLogin) 374 + resultJSON, _ := json.Marshal(map[string]any{ 375 + "is_active": true, 376 + "monthly_amount_cents": 5000, 377 + "tier_name": "Fifty Plus Sponsor", 378 + }) 379 + return string(resultJSON), nil 380 + } 381 + 382 + // Check if any of the user's organizations are in the fifty-plus sponsors list (using REST API org list) 383 + for org := range userOrgs { 384 + if fiftyPlusSponsors[org] { 385 + slog.Info("fetchSponsorship: org in fifty-plus sponsors list", "org", org) 386 + resultJSON, _ := json.Marshal(map[string]any{ 387 + "is_active": true, 388 + "monthly_amount_cents": 5000, 389 + "tier_name": "Fifty Plus Sponsor (via " + org + ")", 390 + }) 391 + return string(resultJSON), nil 392 + } 393 + } 394 + 395 + // Check direct user sponsorship 396 + userSponsorship, err := fetchSponsorshipForEntity(ctx, token, "user", userLogin) 397 + if err != nil { 398 + slog.Warn("fetchSponsorship: failed to check user sponsorship", "err", err) 399 + } else if userSponsorship != nil { 400 + slog.Info("fetchSponsorship: found active user sponsorship", 401 + "user", userLogin, 402 + "tier_name", userSponsorship.Tier.Name, 403 + "monthly_amount_cents", userSponsorship.Tier.MonthlyPriceInCents) 404 + 405 + resultJSON, _ := json.Marshal(map[string]any{ 406 + "is_active": true, 407 + "monthly_amount_cents": userSponsorship.Tier.MonthlyPriceInCents, 408 + "tier_name": userSponsorship.Tier.Name, 409 + }) 410 + return string(resultJSON), nil 411 + } 412 + 413 + // Check organizational sponsorships using the GraphQL results 414 + for org, sponsorship := range userOrgsWithSponsorship { 415 + if sponsorship != nil && sponsorship.IsActive { 416 + slog.Info("fetchSponsorship: found active org sponsorship via GraphQL", 417 + "org", org, 418 + "tier_name", sponsorship.Tier.Name, 419 + "monthly_amount_cents", sponsorship.Tier.MonthlyPriceInCents) 420 + 421 + resultJSON, _ := json.Marshal(map[string]any{ 422 + "is_active": true, 423 + "monthly_amount_cents": sponsorship.Tier.MonthlyPriceInCents, 424 + "tier_name": sponsorship.Tier.Name, 425 + }) 426 + return string(resultJSON), nil 427 + } 428 + } 429 + 430 + slog.Debug("fetchSponsorship: no active sponsorship found", "user", userLogin) 431 + return `{"is_active": false}`, nil 432 + } 433 + 434 + // callbackHandler handles the OAuth callback from GitHub. 435 + func (s *Server) callbackHandler(w http.ResponseWriter, r *http.Request) { 436 + if r.Method != http.MethodGet { 437 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 438 + return 439 + } 440 + 441 + slog.Debug("callbackHandler: received OAuth callback") 442 + 443 + // Verify state for CSRF protection 444 + stateCookie, err := r.Cookie("oauth_state") 445 + if err != nil { 446 + slog.Error("callbackHandler: missing oauth_state cookie") 447 + renderOAuthError(w, "Invalid OAuth state") 448 + return 449 + } 450 + 451 + state := r.URL.Query().Get("state") 452 + if state != stateCookie.Value { 453 + slog.Error("callbackHandler: oauth state mismatch", 454 + "query_state", state[:8]+"...", 455 + "cookie_state", stateCookie.Value[:8]+"...") 456 + renderOAuthError(w, "Invalid OAuth state") 457 + return 458 + } 459 + 460 + slog.Debug("callbackHandler: oauth state verified successfully") 461 + 462 + // Clear the state cookie 463 + http.SetCookie(w, &http.Cookie{ 464 + Name: "oauth_state", 465 + Value: "", 466 + Path: "/", 467 + MaxAge: -1, 468 + HttpOnly: true, 469 + Secure: s.cookieSecure, 470 + }) 471 + 472 + // Exchange code for token 473 + code := r.URL.Query().Get("code") 474 + if code == "" { 475 + slog.Error("callbackHandler: missing authorization code") 476 + renderOAuthError(w, "Missing authorization code") 477 + return 478 + } 479 + 480 + slog.Debug("callbackHandler: exchanging code for token", "code_prefix", code[:8]+"...") 481 + 482 + token, err := s.oauth.Exchange(r.Context(), code) 483 + if err != nil { 484 + slog.Error("callbackHandler: failed to exchange token", "err", err) 485 + renderOAuthError(w, "Failed to exchange token") 486 + return 487 + } 488 + 489 + slog.Debug("callbackHandler: token exchange successful") 490 + 491 + // Fetch user from GitHub 492 + ghUser, err := fetchGitHubUser(r.Context(), token.AccessToken) 493 + if err != nil { 494 + slog.Error("callbackHandler: failed to fetch user", "err", err) 495 + renderOAuthError(w, "Failed to fetch user") 496 + return 497 + } 498 + 499 + slog.Debug("callbackHandler: fetched GitHub user", "github_id", ghUser.ID, "login", ghUser.Login) 500 + 501 + // Fetch user's organizations via REST API (for allowlist checking) 502 + userOrgs, err := fetchUserOrganizations(r.Context(), token.AccessToken) 503 + if err != nil { 504 + slog.Error("callbackHandler: failed to fetch user organizations", "err", err) 505 + // Non-fatal: continue with empty org map 506 + userOrgs = make(map[string]bool) 507 + } 508 + 509 + // Fetch user's organizations with sponsorship info via GraphQL (implementation guide query) 510 + userOrgsWithSponsorship, err := fetchUserOrganizationsWithSponsorship(r.Context(), token.AccessToken, ghUser.Login) 511 + if err != nil { 512 + slog.Error("callbackHandler: failed to fetch user organizations with sponsorship", "err", err) 513 + // Non-fatal: continue with empty org map 514 + userOrgsWithSponsorship = make(map[string]*sponsorshipInfo) 515 + } 516 + 517 + // Fetch sponsorship data (checks allowlist, then user, then org sponsorships) 518 + sponsorData, err := fetchSponsorship(r.Context(), token.AccessToken, ghUser.Login, userOrgs, userOrgsWithSponsorship, s.fiftyPlusSponsors) 519 + if err != nil { 520 + slog.Error("callbackHandler: failed to fetch sponsorship", "err", err) 521 + // Non-fatal: continue with empty sponsorship data 522 + sponsorData = `{"is_active": false}` 523 + } 524 + 525 + slog.Debug("callbackHandler: sponsorship data", "data", sponsorData) 526 + 527 + // Upsert user in database 528 + user := &User{ 529 + GitHubID: ghUser.ID, 530 + Login: ghUser.Login, 531 + AvatarURL: ghUser.AvatarURL, 532 + Name: ghUser.Name, 533 + Email: ghUser.Email, 534 + SponsorshipData: sponsorData, 535 + } 536 + 537 + if err := upsertUser(s.db, user); err != nil { 538 + slog.Error("callbackHandler: failed to upsert user", "err", err, "github_id", ghUser.ID) 539 + renderOAuthError(w, "Failed to create user") 540 + return 541 + } 542 + 543 + slog.Debug("callbackHandler: user upserted successfully", "user_id", user.ID, "github_id", ghUser.ID) 544 + 545 + // Create session with user ID 546 + // Try to get existing session first, but if we get a decode error (old cookie format), create new one 547 + session, err := s.sessionStore.Get(r, "session") 548 + if err != nil { 549 + // Failed to decode existing session (probably old format), create a fresh one 550 + slog.Debug("callbackHandler: failed to decode existing session, creating new one", "err", err) 551 + session = sessions.NewSession(s.sessionStore, "session") 552 + } 553 + session.Values["user_id"] = user.ID 554 + if err := s.sessionStore.Save(r, w, session); err != nil { 555 + slog.Error("callbackHandler: failed to save session", "err", err) 556 + renderOAuthError(w, "Failed to save session") 557 + return 558 + } 559 + 560 + slog.Info("callbackHandler: user logged in successfully", "user_id", user.ID, "login", ghUser.Login) 561 + 562 + // Redirect to dashboard 563 + http.Redirect(w, r, "/", http.StatusFound) 564 + } 565 + 566 + // logoutHandler logs the user out by clearing the session. 567 + func (s *Server) logoutHandler(w http.ResponseWriter, r *http.Request) { 568 + if r.Method != http.MethodGet { 569 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 570 + return 571 + } 572 + 573 + // Get user before logout for logging 574 + user, err := s.getSessionUser(r) 575 + if err == nil { 576 + slog.Info("logoutHandler: user logged out", "user_id", user.ID, "login", user.Login) 577 + } else { 578 + slog.Debug("logoutHandler: no active session to logout") 579 + } 580 + 581 + // Clear the session (ignore decode errors from old cookie format) 582 + session, err := s.sessionStore.Get(r, "session") 583 + if err == nil { 584 + session.Values["user_id"] = nil 585 + session.Options.MaxAge = -1 586 + s.sessionStore.Save(r, w, session) 587 + } else { 588 + // If we can't decode the old session, just clear the cookie 589 + http.SetCookie(w, &http.Cookie{ 590 + Name: "session", 591 + Value: "", 592 + Path: "/", 593 + MaxAge: -1, 594 + HttpOnly: true, 595 + }) 596 + } 597 + 598 + http.Redirect(w, r, "/", http.StatusFound) 599 + } 600 + 601 + // getSessionUser retrieves the user from the session. 602 + func (s *Server) getSessionUser(r *http.Request) (*User, error) { 603 + session, err := s.sessionStore.Get(r, "session") 604 + if err != nil { 605 + // Failed to decode session - might be old format, try to read raw cookie 606 + slog.Debug("getSessionUser: failed to get session, trying old format", "err", err) 607 + cookie, err := r.Cookie("session") 608 + if err != nil { 609 + return nil, fmt.Errorf("no session cookie") 610 + } 611 + var userID int 612 + if _, err := fmt.Sscanf(cookie.Value, "%d", &userID); err != nil { 613 + return nil, fmt.Errorf("invalid session format") 614 + } 615 + if userID == 0 { 616 + return nil, fmt.Errorf("invalid user id in session") 617 + } 618 + slog.Debug("getSessionUser: fetched user from old session format", "user_id", userID) 619 + return getUserByID(s.db, userID) 620 + } 621 + 622 + userID, ok := session.Values["user_id"].(int) 623 + if !ok || userID == 0 { 624 + slog.Debug("getSessionUser: no user_id in session") 625 + return nil, fmt.Errorf("no user_id in session") 626 + } 627 + 628 + slog.Debug("getSessionUser: fetching user from session", "user_id", userID) 629 + return getUserByID(s.db, userID) 630 + } 631 + 632 + // renderOAuthError renders an OAuth error page. 633 + func renderOAuthError(w http.ResponseWriter, message string) { 634 + w.Header().Set("Content-Type", "text/html") 635 + w.WriteHeader(http.StatusBadRequest) 636 + templates.Base("OAuth Error", templates.OAuthError(message)). 637 + Render(context.Background(), w) 638 + }
+1
cmd/sponsor-panel/static/css/.gitignore
··· 1 + *.css
cmd/sponsor-panel/static/favicon.ico

This is a binary file and will not be displayed.

+37
cmd/sponsor-panel/styles.css
··· 1 + @import url("https://cdn.xeiaso.net/static/pkg/iosevka/family.css"); 2 + @import url("https://cdn.xeiaso.net/static/pkg/podkova/family.css"); 3 + 4 + @import "tailwindcss/preflight"; 5 + @import "tailwindcss/utilities"; 6 + 7 + @theme { 8 + --font-sans: "Iosevka Aile Iaso", sans-serif; 9 + --font-mono: "Iosevka Curly Iaso", monospace; 10 + --font-serif: "Podkova", serif; 11 + } 12 + 13 + @layer base { 14 + @reference "tailwindcss"; 15 + 16 + h1, 17 + h2, 18 + h3, 19 + h4, 20 + h5, 21 + h6 { 22 + @apply font-serif; 23 + } 24 + 25 + .xe-hero-image { 26 + background-image: url("/static/img/hero.avif"); 27 + background-size: cover; 28 + background-position: center; 29 + } 30 + 31 + .frosted-glass { 32 + backdrop-filter: blur(10px); 33 + background-color: rgba(255, 255, 255, 0.7); 34 + border-radius: 12px; 35 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 36 + } 37 + }
+14
cmd/sponsor-panel/tailwind.config.js
··· 1 + /** @type {import('tailwindcss').Config} */ 2 + module.exports = { 3 + content: ["./*.templ", "./templates/**/*.templ"], 4 + theme: { 5 + extend: { 6 + fontFamily: { 7 + sans: ["Iosevka Aile Iaso", "sans-serif"], 8 + mono: ["Iosevka Curly Iaso", "monospace"], 9 + serif: ["Podkova", "serif"], 10 + }, 11 + }, 12 + }, 13 + plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")], 14 + };
+23
cmd/sponsor-panel/templates/base.templ
··· 1 + package templates 2 + 3 + import "xeiaso.net/v4/web/htmx" 4 + 5 + templ Base(title string, content templ.Component) { 6 + <!DOCTYPE html> 7 + <html lang="en"> 8 + <head> 9 + <meta charset="UTF-8"/> 10 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 11 + if title == "" { 12 + <title>Xe Iaso Sponsor Panel</title> 13 + } else { 14 + <title>{ title } - Xe Iaso Sponsor Panel</title> 15 + } 16 + <link rel="stylesheet" href="/static/css/styles.css"/> 17 + @htmx.Use("remove-me") 18 + </head> 19 + <body class="bg-gray-50 dark:bg-gray-900"> 20 + @content 21 + </body> 22 + </html> 23 + }
+86
cmd/sponsor-panel/templates/base_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.865 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + import "xeiaso.net/v4/web/htmx" 12 + 13 + func Base(title string, content templ.Component) templ.Component { 14 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 15 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 16 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 17 + return templ_7745c5c3_CtxErr 18 + } 19 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 20 + if !templ_7745c5c3_IsBuffer { 21 + defer func() { 22 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 23 + if templ_7745c5c3_Err == nil { 24 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 25 + } 26 + }() 27 + } 28 + ctx = templ.InitializeContext(ctx) 29 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 30 + if templ_7745c5c3_Var1 == nil { 31 + templ_7745c5c3_Var1 = templ.NopComponent 32 + } 33 + ctx = templ.ClearChildren(ctx) 34 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">") 35 + if templ_7745c5c3_Err != nil { 36 + return templ_7745c5c3_Err 37 + } 38 + if title == "" { 39 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<title>Xe Iaso Sponsor Panel</title>") 40 + if templ_7745c5c3_Err != nil { 41 + return templ_7745c5c3_Err 42 + } 43 + } else { 44 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<title>") 45 + if templ_7745c5c3_Err != nil { 46 + return templ_7745c5c3_Err 47 + } 48 + var templ_7745c5c3_Var2 string 49 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) 50 + if templ_7745c5c3_Err != nil { 51 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/base.templ`, Line: 14, Col: 18} 52 + } 53 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 54 + if templ_7745c5c3_Err != nil { 55 + return templ_7745c5c3_Err 56 + } 57 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " - Xe Iaso Sponsor Panel</title>") 58 + if templ_7745c5c3_Err != nil { 59 + return templ_7745c5c3_Err 60 + } 61 + } 62 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<link rel=\"stylesheet\" href=\"/static/css/styles.css\">") 63 + if templ_7745c5c3_Err != nil { 64 + return templ_7745c5c3_Err 65 + } 66 + templ_7745c5c3_Err = htmx.Use("remove-me").Render(ctx, templ_7745c5c3_Buffer) 67 + if templ_7745c5c3_Err != nil { 68 + return templ_7745c5c3_Err 69 + } 70 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</head><body class=\"bg-gray-50 dark:bg-gray-900\">") 71 + if templ_7745c5c3_Err != nil { 72 + return templ_7745c5c3_Err 73 + } 74 + templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) 75 + if templ_7745c5c3_Err != nil { 76 + return templ_7745c5c3_Err 77 + } 78 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</body></html>") 79 + if templ_7745c5c3_Err != nil { 80 + return templ_7745c5c3_Err 81 + } 82 + return nil 83 + }) 84 + } 85 + 86 + var _ = templruntime.GeneratedTemplate
+140
cmd/sponsor-panel/templates/dashboard.templ
··· 1 + package templates 2 + 3 + // DashboardProps contains the data needed to render the dashboard 4 + type DashboardProps struct { 5 + DiscordInvite string 6 + User UserProps 7 + IsSponsor bool 8 + SponsorAmount int 9 + SponsorTier string 10 + IsFiftyPlus bool 11 + } 12 + 13 + // UserProps contains user information 14 + type UserProps struct { 15 + Login string 16 + AvatarURL string 17 + } 18 + 19 + templ Dashboard(props DashboardProps) { 20 + @Navbar(props.User.Login, props.User.AvatarURL) 21 + <main class="max-w-4xl mx-auto px-4 py-8"> 22 + <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Welcome, { props.User.Login }!</h1> 23 + <div class="grid md:grid-cols-2 gap-6"> 24 + if props.IsSponsor { 25 + @DiscordCard(props.DiscordInvite) 26 + } 27 + @SponsorshipCard(props.IsSponsor, props.SponsorAmount, props.SponsorTier) 28 + </div> 29 + <div class="grid md:grid-cols-2 gap-6 mt-6"> 30 + if props.IsFiftyPlus { 31 + @TeamInviteCard() 32 + } 33 + if props.IsSponsor { 34 + @LogoSubmitCard() 35 + } 36 + </div> 37 + </main> 38 + } 39 + 40 + templ Navbar(login, avatarURL string) { 41 + <nav class="bg-white dark:bg-slate-800/90 border-b border-gray-200 dark:border-slate-700 px-4 py-3"> 42 + <div class="max-w-4xl mx-auto flex items-center justify-between"> 43 + <div class="flex items-center gap-3"> 44 + <img src={ templ.SafeURL(avatarURL) } class="w-8 h-8 rounded-full" alt=""/> 45 + <span class="font-semibold text-gray-900 dark:text-gray-100">{ login }</span> 46 + </div> 47 + <a href="/logout" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 48 + Logout 49 + </a> 50 + </div> 51 + </nav> 52 + } 53 + 54 + templ DiscordCard(inviteURL string) { 55 + <div class="bg-white dark:bg-slate-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700"> 56 + <h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Discord Community</h2> 57 + <p class="text-sm text-gray-600 dark:text-gray-300 mb-4"> 58 + Join our Discord server to chat with other sponsors. 59 + </p> 60 + <a href={ templ.SafeURL(inviteURL) } target="_blank" class="inline-block bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm transition-colors"> 61 + Join Discord 62 + </a> 63 + </div> 64 + } 65 + 66 + templ SponsorshipCard(isSponsor bool, amount int, tier string) { 67 + <div class="bg-white dark:bg-slate-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700"> 68 + <h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Your Sponsorship</h2> 69 + if isSponsor { 70 + <p class="text-green-600 dark:text-green-400"> 71 + ${ formatDollars(amount) }/month - { tier } 72 + </p> 73 + <p class="text-sm text-gray-600 dark:text-gray-300 mt-1">Thank you for your support!</p> 74 + } else { 75 + <p class="text-gray-600 dark:text-gray-300">Not an active sponsor.</p> 76 + <a href="https://github.com/sponsors/Xe" target="_blank" class="inline-block bg-pink-600 hover:bg-pink-700 text-white px-4 py-2 rounded-lg text-sm mt-2 transition-colors"> 77 + Become a Sponsor 78 + </a> 79 + <p class="text-sm text-gray-600 dark:text-gray-300 mt-1">If you are a part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.</p> 80 + } 81 + </div> 82 + } 83 + 84 + templ TeamInviteCard() { 85 + <div class="bg-white dark:bg-slate-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700"> 86 + <h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Team Invitation</h2> 87 + <p class="text-sm text-gray-600 dark:text-gray-300 mb-4"> 88 + Invite team members to TecharoHQ. 89 + </p> 90 + <form hx-post="/invite" hx-target="#invite-result" class="space-y-3"> 91 + <input 92 + type="text" 93 + name="username" 94 + placeholder="GitHub username" 95 + required 96 + class="w-full border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 dark:bg-slate-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" 97 + /> 98 + <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm w-full transition-colors"> 99 + Invite to Team 100 + </button> 101 + </form> 102 + <div id="invite-result"></div> 103 + </div> 104 + } 105 + 106 + templ LogoSubmitCard() { 107 + <div class="bg-white dark:bg-slate-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700"> 108 + <h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Logo Submission</h2> 109 + <p class="text-sm text-gray-600 dark:text-gray-300 mb-4"> 110 + Submit your company logo for the Anubis README. 111 + </p> 112 + <form hx-post="/logo" hx-encoding="multipart/form-data" hx-target="#logo-result" class="space-y-3"> 113 + <input 114 + type="text" 115 + name="company" 116 + placeholder="Company Name" 117 + required 118 + class="w-full border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 dark:bg-slate-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400" 119 + /> 120 + <input 121 + type="url" 122 + name="website" 123 + placeholder="Website URL" 124 + required 125 + class="w-full border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 dark:bg-slate-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400" 126 + /> 127 + <input 128 + type="file" 129 + name="logo" 130 + accept="image/png,image/jpeg,image/svg+xml" 131 + required 132 + class="w-full border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 dark:bg-slate-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400" 133 + /> 134 + <button type="submit" class="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-lg text-sm w-full transition-colors"> 135 + Submit Logo 136 + </button> 137 + </form> 138 + <div id="logo-result"></div> 139 + </div> 140 + }
+324
cmd/sponsor-panel/templates/dashboard_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.865 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + // DashboardProps contains the data needed to render the dashboard 12 + type DashboardProps struct { 13 + DiscordInvite string 14 + User UserProps 15 + IsSponsor bool 16 + SponsorAmount int 17 + SponsorTier string 18 + IsFiftyPlus bool 19 + } 20 + 21 + // UserProps contains user information 22 + type UserProps struct { 23 + Login string 24 + AvatarURL string 25 + } 26 + 27 + func Dashboard(props DashboardProps) templ.Component { 28 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 29 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 30 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 31 + return templ_7745c5c3_CtxErr 32 + } 33 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 34 + if !templ_7745c5c3_IsBuffer { 35 + defer func() { 36 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 37 + if templ_7745c5c3_Err == nil { 38 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 39 + } 40 + }() 41 + } 42 + ctx = templ.InitializeContext(ctx) 43 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 44 + if templ_7745c5c3_Var1 == nil { 45 + templ_7745c5c3_Var1 = templ.NopComponent 46 + } 47 + ctx = templ.ClearChildren(ctx) 48 + templ_7745c5c3_Err = Navbar(props.User.Login, props.User.AvatarURL).Render(ctx, templ_7745c5c3_Buffer) 49 + if templ_7745c5c3_Err != nil { 50 + return templ_7745c5c3_Err 51 + } 52 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><h1 class=\"text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6\">Welcome, ") 53 + if templ_7745c5c3_Err != nil { 54 + return templ_7745c5c3_Err 55 + } 56 + var templ_7745c5c3_Var2 string 57 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(props.User.Login) 58 + if templ_7745c5c3_Err != nil { 59 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 22, Col: 98} 60 + } 61 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 62 + if templ_7745c5c3_Err != nil { 63 + return templ_7745c5c3_Err 64 + } 65 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "!</h1><div class=\"grid md:grid-cols-2 gap-6\">") 66 + if templ_7745c5c3_Err != nil { 67 + return templ_7745c5c3_Err 68 + } 69 + if props.IsSponsor { 70 + templ_7745c5c3_Err = DiscordCard(props.DiscordInvite).Render(ctx, templ_7745c5c3_Buffer) 71 + if templ_7745c5c3_Err != nil { 72 + return templ_7745c5c3_Err 73 + } 74 + } 75 + templ_7745c5c3_Err = SponsorshipCard(props.IsSponsor, props.SponsorAmount, props.SponsorTier).Render(ctx, templ_7745c5c3_Buffer) 76 + if templ_7745c5c3_Err != nil { 77 + return templ_7745c5c3_Err 78 + } 79 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div><div class=\"grid md:grid-cols-2 gap-6 mt-6\">") 80 + if templ_7745c5c3_Err != nil { 81 + return templ_7745c5c3_Err 82 + } 83 + if props.IsFiftyPlus { 84 + templ_7745c5c3_Err = TeamInviteCard().Render(ctx, templ_7745c5c3_Buffer) 85 + if templ_7745c5c3_Err != nil { 86 + return templ_7745c5c3_Err 87 + } 88 + } 89 + if props.IsSponsor { 90 + templ_7745c5c3_Err = LogoSubmitCard().Render(ctx, templ_7745c5c3_Buffer) 91 + if templ_7745c5c3_Err != nil { 92 + return templ_7745c5c3_Err 93 + } 94 + } 95 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></main>") 96 + if templ_7745c5c3_Err != nil { 97 + return templ_7745c5c3_Err 98 + } 99 + return nil 100 + }) 101 + } 102 + 103 + func Navbar(login, avatarURL string) templ.Component { 104 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 105 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 106 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 107 + return templ_7745c5c3_CtxErr 108 + } 109 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 110 + if !templ_7745c5c3_IsBuffer { 111 + defer func() { 112 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 113 + if templ_7745c5c3_Err == nil { 114 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 115 + } 116 + }() 117 + } 118 + ctx = templ.InitializeContext(ctx) 119 + templ_7745c5c3_Var3 := templ.GetChildren(ctx) 120 + if templ_7745c5c3_Var3 == nil { 121 + templ_7745c5c3_Var3 = templ.NopComponent 122 + } 123 + ctx = templ.ClearChildren(ctx) 124 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<nav class=\"bg-white dark:bg-slate-800/90 border-b border-gray-200 dark:border-slate-700 px-4 py-3\"><div class=\"max-w-4xl mx-auto flex items-center justify-between\"><div class=\"flex items-center gap-3\"><img src=\"") 125 + if templ_7745c5c3_Err != nil { 126 + return templ_7745c5c3_Err 127 + } 128 + var templ_7745c5c3_Var4 string 129 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.SafeURL(avatarURL)) 130 + if templ_7745c5c3_Err != nil { 131 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 44, Col: 39} 132 + } 133 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 134 + if templ_7745c5c3_Err != nil { 135 + return templ_7745c5c3_Err 136 + } 137 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"w-8 h-8 rounded-full\" alt=\"\"> <span class=\"font-semibold text-gray-900 dark:text-gray-100\">") 138 + if templ_7745c5c3_Err != nil { 139 + return templ_7745c5c3_Err 140 + } 141 + var templ_7745c5c3_Var5 string 142 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(login) 143 + if templ_7745c5c3_Err != nil { 144 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 72} 145 + } 146 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 147 + if templ_7745c5c3_Err != nil { 148 + return templ_7745c5c3_Err 149 + } 150 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></div><a href=\"/logout\" class=\"text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors\">Logout</a></div></nav>") 151 + if templ_7745c5c3_Err != nil { 152 + return templ_7745c5c3_Err 153 + } 154 + return nil 155 + }) 156 + } 157 + 158 + func DiscordCard(inviteURL string) templ.Component { 159 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 160 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 161 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 162 + return templ_7745c5c3_CtxErr 163 + } 164 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 165 + if !templ_7745c5c3_IsBuffer { 166 + defer func() { 167 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 168 + if templ_7745c5c3_Err == nil { 169 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 170 + } 171 + }() 172 + } 173 + ctx = templ.InitializeContext(ctx) 174 + templ_7745c5c3_Var6 := templ.GetChildren(ctx) 175 + if templ_7745c5c3_Var6 == nil { 176 + templ_7745c5c3_Var6 = templ.NopComponent 177 + } 178 + ctx = templ.ClearChildren(ctx) 179 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"bg-white dark:bg-slate-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700\"><h2 class=\"font-semibold text-gray-900 dark:text-gray-100 mb-2\">Discord Community</h2><p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Join our Discord server to chat with other sponsors.</p><a href=\"") 180 + if templ_7745c5c3_Err != nil { 181 + return templ_7745c5c3_Err 182 + } 183 + var templ_7745c5c3_Var7 templ.SafeURL = templ.SafeURL(inviteURL) 184 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var7))) 185 + if templ_7745c5c3_Err != nil { 186 + return templ_7745c5c3_Err 187 + } 188 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" target=\"_blank\" class=\"inline-block bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm transition-colors\">Join Discord</a></div>") 189 + if templ_7745c5c3_Err != nil { 190 + return templ_7745c5c3_Err 191 + } 192 + return nil 193 + }) 194 + } 195 + 196 + func SponsorshipCard(isSponsor bool, amount int, tier string) templ.Component { 197 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 198 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 199 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 200 + return templ_7745c5c3_CtxErr 201 + } 202 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 203 + if !templ_7745c5c3_IsBuffer { 204 + defer func() { 205 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 206 + if templ_7745c5c3_Err == nil { 207 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 208 + } 209 + }() 210 + } 211 + ctx = templ.InitializeContext(ctx) 212 + templ_7745c5c3_Var8 := templ.GetChildren(ctx) 213 + if templ_7745c5c3_Var8 == nil { 214 + templ_7745c5c3_Var8 = templ.NopComponent 215 + } 216 + ctx = templ.ClearChildren(ctx) 217 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"bg-white dark:bg-slate-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700\"><h2 class=\"font-semibold text-gray-900 dark:text-gray-100 mb-2\">Your Sponsorship</h2>") 218 + if templ_7745c5c3_Err != nil { 219 + return templ_7745c5c3_Err 220 + } 221 + if isSponsor { 222 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<p class=\"text-green-600 dark:text-green-400\">$") 223 + if templ_7745c5c3_Err != nil { 224 + return templ_7745c5c3_Err 225 + } 226 + var templ_7745c5c3_Var9 string 227 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatDollars(amount)) 228 + if templ_7745c5c3_Err != nil { 229 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 71, Col: 28} 230 + } 231 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 232 + if templ_7745c5c3_Err != nil { 233 + return templ_7745c5c3_Err 234 + } 235 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "/month - ") 236 + if templ_7745c5c3_Err != nil { 237 + return templ_7745c5c3_Err 238 + } 239 + var templ_7745c5c3_Var10 string 240 + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tier) 241 + if templ_7745c5c3_Err != nil { 242 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 71, Col: 45} 243 + } 244 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 245 + if templ_7745c5c3_Err != nil { 246 + return templ_7745c5c3_Err 247 + } 248 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p><p class=\"text-sm text-gray-600 dark:text-gray-300 mt-1\">Thank you for your support!</p>") 249 + if templ_7745c5c3_Err != nil { 250 + return templ_7745c5c3_Err 251 + } 252 + } else { 253 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<p class=\"text-gray-600 dark:text-gray-300\">Not an active sponsor.</p><a href=\"https://github.com/sponsors/Xe\" target=\"_blank\" class=\"inline-block bg-pink-600 hover:bg-pink-700 text-white px-4 py-2 rounded-lg text-sm mt-2 transition-colors\">Become a Sponsor</a><p class=\"text-sm text-gray-600 dark:text-gray-300 mt-1\">If you are a part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.</p>") 254 + if templ_7745c5c3_Err != nil { 255 + return templ_7745c5c3_Err 256 + } 257 + } 258 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>") 259 + if templ_7745c5c3_Err != nil { 260 + return templ_7745c5c3_Err 261 + } 262 + return nil 263 + }) 264 + } 265 + 266 + func TeamInviteCard() templ.Component { 267 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 268 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 269 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 270 + return templ_7745c5c3_CtxErr 271 + } 272 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 273 + if !templ_7745c5c3_IsBuffer { 274 + defer func() { 275 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 276 + if templ_7745c5c3_Err == nil { 277 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 278 + } 279 + }() 280 + } 281 + ctx = templ.InitializeContext(ctx) 282 + templ_7745c5c3_Var11 := templ.GetChildren(ctx) 283 + if templ_7745c5c3_Var11 == nil { 284 + templ_7745c5c3_Var11 = templ.NopComponent 285 + } 286 + ctx = templ.ClearChildren(ctx) 287 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"bg-white dark:bg-slate-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700\"><h2 class=\"font-semibold text-gray-900 dark:text-gray-100 mb-2\">Team Invitation</h2><p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Invite team members to TecharoHQ.</p><form hx-post=\"/invite\" hx-target=\"#invite-result\" class=\"space-y-3\"><input type=\"text\" name=\"username\" placeholder=\"GitHub username\" required class=\"w-full border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 dark:bg-slate-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400\"> <button type=\"submit\" class=\"bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm w-full transition-colors\">Invite to Team</button></form><div id=\"invite-result\"></div></div>") 288 + if templ_7745c5c3_Err != nil { 289 + return templ_7745c5c3_Err 290 + } 291 + return nil 292 + }) 293 + } 294 + 295 + func LogoSubmitCard() templ.Component { 296 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 297 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 298 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 299 + return templ_7745c5c3_CtxErr 300 + } 301 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 302 + if !templ_7745c5c3_IsBuffer { 303 + defer func() { 304 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 305 + if templ_7745c5c3_Err == nil { 306 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 307 + } 308 + }() 309 + } 310 + ctx = templ.InitializeContext(ctx) 311 + templ_7745c5c3_Var12 := templ.GetChildren(ctx) 312 + if templ_7745c5c3_Var12 == nil { 313 + templ_7745c5c3_Var12 = templ.NopComponent 314 + } 315 + ctx = templ.ClearChildren(ctx) 316 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"bg-white dark:bg-slate-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700\"><h2 class=\"font-semibold text-gray-900 dark:text-gray-100 mb-2\">Logo Submission</h2><p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">Submit your company logo for the Anubis README.</p><form hx-post=\"/logo\" hx-encoding=\"multipart/form-data\" hx-target=\"#logo-result\" class=\"space-y-3\"><input type=\"text\" name=\"company\" placeholder=\"Company Name\" required class=\"w-full border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 dark:bg-slate-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400\"> <input type=\"url\" name=\"website\" placeholder=\"Website URL\" required class=\"w-full border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 dark:bg-slate-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400\"> <input type=\"file\" name=\"logo\" accept=\"image/png,image/jpeg,image/svg+xml\" required class=\"w-full border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 dark:bg-slate-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400\"> <button type=\"submit\" class=\"bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-lg text-sm w-full transition-colors\">Submit Logo</button></form><div id=\"logo-result\"></div></div>") 317 + if templ_7745c5c3_Err != nil { 318 + return templ_7745c5c3_Err 319 + } 320 + return nil 321 + }) 322 + } 323 + 324 + var _ = templruntime.GeneratedTemplate
+13
cmd/sponsor-panel/templates/formresult.templ
··· 1 + package templates 2 + 3 + templ FormResult(message string, isSuccess bool) { 4 + if isSuccess { 5 + <div class="bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 p-4 rounded-lg"> 6 + { message } 7 + </div> 8 + } else { 9 + <div class="bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 p-4 rounded-lg"> 10 + { message } 11 + </div> 12 + } 13 + }
+73
cmd/sponsor-panel/templates/formresult_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.865 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + func FormResult(message string, isSuccess bool) templ.Component { 12 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 + return templ_7745c5c3_CtxErr 16 + } 17 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 + if !templ_7745c5c3_IsBuffer { 19 + defer func() { 20 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 + if templ_7745c5c3_Err == nil { 22 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 + } 24 + }() 25 + } 26 + ctx = templ.InitializeContext(ctx) 27 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 + if templ_7745c5c3_Var1 == nil { 29 + templ_7745c5c3_Var1 = templ.NopComponent 30 + } 31 + ctx = templ.ClearChildren(ctx) 32 + if isSuccess { 33 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 p-4 rounded-lg\">") 34 + if templ_7745c5c3_Err != nil { 35 + return templ_7745c5c3_Err 36 + } 37 + var templ_7745c5c3_Var2 string 38 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message) 39 + if templ_7745c5c3_Err != nil { 40 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formresult.templ`, Line: 6, Col: 12} 41 + } 42 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 43 + if templ_7745c5c3_Err != nil { 44 + return templ_7745c5c3_Err 45 + } 46 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>") 47 + if templ_7745c5c3_Err != nil { 48 + return templ_7745c5c3_Err 49 + } 50 + } else { 51 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 p-4 rounded-lg\">") 52 + if templ_7745c5c3_Err != nil { 53 + return templ_7745c5c3_Err 54 + } 55 + var templ_7745c5c3_Var3 string 56 + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(message) 57 + if templ_7745c5c3_Err != nil { 58 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formresult.templ`, Line: 10, Col: 12} 59 + } 60 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 61 + if templ_7745c5c3_Err != nil { 62 + return templ_7745c5c3_Err 63 + } 64 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>") 65 + if templ_7745c5c3_Err != nil { 66 + return templ_7745c5c3_Err 67 + } 68 + } 69 + return nil 70 + }) 71 + } 72 + 73 + var _ = templruntime.GeneratedTemplate
+26
cmd/sponsor-panel/templates/formsuccess.templ
··· 1 + package templates 2 + 3 + // InviteSuccess renders the success response for team invitation. 4 + templ InviteSuccess(username, state string) { 5 + <div class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg"> 6 + <p class="text-green-700 dark:text-green-300 font-semibold">Invitation sent!</p> 7 + <p class="text-sm text-gray-600 dark:text-gray-400 mt-1"> 8 + { "@" + username } is { state } 9 + </p> 10 + </div> 11 + } 12 + 13 + // LogoSuccess renders the success response for logo submission. 14 + templ LogoSuccess(company, issueURL string, issueNumber int) { 15 + <div class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg"> 16 + <p class="text-green-700 dark:text-green-300 font-semibold">Logo submitted!</p> 17 + <p class="text-sm text-gray-600 dark:text-gray-400 mt-1"> 18 + Your logo for { company } has been submitted for review. 19 + </p> 20 + <p class="text-sm mt-2"> 21 + <a href={ templ.SafeURL(issueURL) } target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline"> 22 + View issue #{ issueNumber } 23 + </a> 24 + </p> 25 + </div> 26 + }
+132
cmd/sponsor-panel/templates/formsuccess_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.865 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + // InviteSuccess renders the success response for team invitation. 12 + func InviteSuccess(username, state string) templ.Component { 13 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 14 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 15 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 16 + return templ_7745c5c3_CtxErr 17 + } 18 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 19 + if !templ_7745c5c3_IsBuffer { 20 + defer func() { 21 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 22 + if templ_7745c5c3_Err == nil { 23 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 24 + } 25 + }() 26 + } 27 + ctx = templ.InitializeContext(ctx) 28 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 29 + if templ_7745c5c3_Var1 == nil { 30 + templ_7745c5c3_Var1 = templ.NopComponent 31 + } 32 + ctx = templ.ClearChildren(ctx) 33 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"bg-green-50 dark:bg-green-900/20 p-4 rounded-lg\"><p class=\"text-green-700 dark:text-green-300 font-semibold\">Invitation sent!</p><p class=\"text-sm text-gray-600 dark:text-gray-400 mt-1\">") 34 + if templ_7745c5c3_Err != nil { 35 + return templ_7745c5c3_Err 36 + } 37 + var templ_7745c5c3_Var2 string 38 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("@" + username) 39 + if templ_7745c5c3_Err != nil { 40 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formsuccess.templ`, Line: 8, Col: 19} 41 + } 42 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 43 + if templ_7745c5c3_Err != nil { 44 + return templ_7745c5c3_Err 45 + } 46 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " is ") 47 + if templ_7745c5c3_Err != nil { 48 + return templ_7745c5c3_Err 49 + } 50 + var templ_7745c5c3_Var3 string 51 + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(state) 52 + if templ_7745c5c3_Err != nil { 53 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formsuccess.templ`, Line: 8, Col: 32} 54 + } 55 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 56 + if templ_7745c5c3_Err != nil { 57 + return templ_7745c5c3_Err 58 + } 59 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p></div>") 60 + if templ_7745c5c3_Err != nil { 61 + return templ_7745c5c3_Err 62 + } 63 + return nil 64 + }) 65 + } 66 + 67 + // LogoSuccess renders the success response for logo submission. 68 + func LogoSuccess(company, issueURL string, issueNumber int) templ.Component { 69 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 70 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 71 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 72 + return templ_7745c5c3_CtxErr 73 + } 74 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 75 + if !templ_7745c5c3_IsBuffer { 76 + defer func() { 77 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 78 + if templ_7745c5c3_Err == nil { 79 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 80 + } 81 + }() 82 + } 83 + ctx = templ.InitializeContext(ctx) 84 + templ_7745c5c3_Var4 := templ.GetChildren(ctx) 85 + if templ_7745c5c3_Var4 == nil { 86 + templ_7745c5c3_Var4 = templ.NopComponent 87 + } 88 + ctx = templ.ClearChildren(ctx) 89 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"bg-green-50 dark:bg-green-900/20 p-4 rounded-lg\"><p class=\"text-green-700 dark:text-green-300 font-semibold\">Logo submitted!</p><p class=\"text-sm text-gray-600 dark:text-gray-400 mt-1\">Your logo for ") 90 + if templ_7745c5c3_Err != nil { 91 + return templ_7745c5c3_Err 92 + } 93 + var templ_7745c5c3_Var5 string 94 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(company) 95 + if templ_7745c5c3_Err != nil { 96 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formsuccess.templ`, Line: 18, Col: 26} 97 + } 98 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 99 + if templ_7745c5c3_Err != nil { 100 + return templ_7745c5c3_Err 101 + } 102 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " has been submitted for review.</p><p class=\"text-sm mt-2\"><a href=\"") 103 + if templ_7745c5c3_Err != nil { 104 + return templ_7745c5c3_Err 105 + } 106 + var templ_7745c5c3_Var6 templ.SafeURL = templ.SafeURL(issueURL) 107 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6))) 108 + if templ_7745c5c3_Err != nil { 109 + return templ_7745c5c3_Err 110 + } 111 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" target=\"_blank\" class=\"text-blue-600 dark:text-blue-400 hover:underline\">View issue #") 112 + if templ_7745c5c3_Err != nil { 113 + return templ_7745c5c3_Err 114 + } 115 + var templ_7745c5c3_Var7 string 116 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issueNumber) 117 + if templ_7745c5c3_Err != nil { 118 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formsuccess.templ`, Line: 22, Col: 29} 119 + } 120 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 121 + if templ_7745c5c3_Err != nil { 122 + return templ_7745c5c3_Err 123 + } 124 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</a></p></div>") 125 + if templ_7745c5c3_Err != nil { 126 + return templ_7745c5c3_Err 127 + } 128 + return nil 129 + }) 130 + } 131 + 132 + var _ = templruntime.GeneratedTemplate
+8
cmd/sponsor-panel/templates/helpers.go
··· 1 + package templates 2 + 3 + import "fmt" 4 + 5 + // formatDollars converts cents to a dollar string representation. 6 + func formatDollars(cents int) string { 7 + return fmt.Sprintf("%.2f", float64(cents)/100) 8 + }
+27
cmd/sponsor-panel/templates/login.templ
··· 1 + package templates 2 + 3 + templ Login() { 4 + <div class="min-h-screen flex items-center justify-center px-4"> 5 + <div class="max-w-md w-full bg-white dark:bg-slate-800/80 backdrop-blur-sm rounded-xl p-8 shadow-lg border border-gray-200 dark:border-slate-700"> 6 + <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Sponsor Panel</h1> 7 + <p class="text-gray-600 dark:text-gray-300 mb-6">Manage your sponsorship benefits</p> 8 + <a href="/login" class="block w-full bg-gray-900 dark:bg-orange-600 hover:bg-gray-800 dark:hover:bg-orange-700 text-white text-center py-3 rounded-lg mb-6 transition-colors"> 9 + Login with GitHub 10 + </a> 11 + <div class="border-t border-gray-200 dark:border-slate-700 pt-4"> 12 + <h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Benefits:</h3> 13 + <ul class="text-sm text-gray-600 dark:text-gray-300 space-y-1"> 14 + <li>✓ All sponsors: Discord access</li> 15 + <li>✓ $50+/month: Team invitations</li> 16 + <li>✓ All sponsors: Logo submission</li> 17 + </ul> 18 + </div> 19 + <div class="mt-4 bg-red-300 dark:bg-red-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700"> 20 + <h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Important note</h2> 21 + <p class="text-sm text-gray-600 dark:text-gray-300 mb-4"> 22 + If you are sponsoring via an organization, please make sure to grant the app access to that organization. 23 + </p> 24 + </div> 25 + </div> 26 + </div> 27 + }
+40
cmd/sponsor-panel/templates/login_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.865 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + func Login() templ.Component { 12 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 + return templ_7745c5c3_CtxErr 16 + } 17 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 + if !templ_7745c5c3_IsBuffer { 19 + defer func() { 20 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 + if templ_7745c5c3_Err == nil { 22 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 + } 24 + }() 25 + } 26 + ctx = templ.InitializeContext(ctx) 27 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 + if templ_7745c5c3_Var1 == nil { 29 + templ_7745c5c3_Var1 = templ.NopComponent 30 + } 31 + ctx = templ.ClearChildren(ctx) 32 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"min-h-screen flex items-center justify-center px-4\"><div class=\"max-w-md w-full bg-white dark:bg-slate-800/80 backdrop-blur-sm rounded-xl p-8 shadow-lg border border-gray-200 dark:border-slate-700\"><h1 class=\"text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2\">Sponsor Panel</h1><p class=\"text-gray-600 dark:text-gray-300 mb-6\">Manage your sponsorship benefits</p><a href=\"/login\" class=\"block w-full bg-gray-900 dark:bg-orange-600 hover:bg-gray-800 dark:hover:bg-orange-700 text-white text-center py-3 rounded-lg mb-6 transition-colors\">Login with GitHub</a><div class=\"border-t border-gray-200 dark:border-slate-700 pt-4\"><h3 class=\"font-semibold text-gray-900 dark:text-gray-100 mb-2\">Benefits:</h3><ul class=\"text-sm text-gray-600 dark:text-gray-300 space-y-1\"><li>✓ All sponsors: Discord access</li><li>✓ $50+/month: Team invitations</li><li>✓ All sponsors: Logo submission</li></ul></div><div class=\"mt-4 bg-red-300 dark:bg-red-800/80 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-slate-700\"><h2 class=\"font-semibold text-gray-900 dark:text-gray-100 mb-2\">Important note</h2><p class=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">If you are sponsoring via an organization, please make sure to grant the app access to that organization.</p></div></div></div>") 33 + if templ_7745c5c3_Err != nil { 34 + return templ_7745c5c3_Err 35 + } 36 + return nil 37 + }) 38 + } 39 + 40 + var _ = templruntime.GeneratedTemplate
+13
cmd/sponsor-panel/templates/oautherror.templ
··· 1 + package templates 2 + 3 + templ OAuthError(message string) { 4 + <div class="min-h-screen flex items-center justify-center px-4"> 5 + <div class="max-w-md w-full bg-red-50 dark:bg-red-950/50 rounded-xl p-8 text-center border border-red-200 dark:border-red-900"> 6 + <h1 class="text-xl font-bold text-red-900 dark:text-red-200 mb-2">Authentication Failed</h1> 7 + <p class="text-red-700 dark:text-red-300 mb-6">{ message }</p> 8 + <a href="/login" class="inline-block bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg transition-colors"> 9 + Try Again 10 + </a> 11 + </div> 12 + </div> 13 + }
+53
cmd/sponsor-panel/templates/oautherror_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.865 4 + package templates 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + func OAuthError(message string) templ.Component { 12 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 + return templ_7745c5c3_CtxErr 16 + } 17 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 + if !templ_7745c5c3_IsBuffer { 19 + defer func() { 20 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 + if templ_7745c5c3_Err == nil { 22 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 + } 24 + }() 25 + } 26 + ctx = templ.InitializeContext(ctx) 27 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 + if templ_7745c5c3_Var1 == nil { 29 + templ_7745c5c3_Var1 = templ.NopComponent 30 + } 31 + ctx = templ.ClearChildren(ctx) 32 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"min-h-screen flex items-center justify-center px-4\"><div class=\"max-w-md w-full bg-red-50 dark:bg-red-950/50 rounded-xl p-8 text-center border border-red-200 dark:border-red-900\"><h1 class=\"text-xl font-bold text-red-900 dark:text-red-200 mb-2\">Authentication Failed</h1><p class=\"text-red-700 dark:text-red-300 mb-6\">") 33 + if templ_7745c5c3_Err != nil { 34 + return templ_7745c5c3_Err 35 + } 36 + var templ_7745c5c3_Var2 string 37 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message) 38 + if templ_7745c5c3_Err != nil { 39 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/oautherror.templ`, Line: 7, Col: 59} 40 + } 41 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 42 + if templ_7745c5c3_Err != nil { 43 + return templ_7745c5c3_Err 44 + } 45 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</p><a href=\"/login\" class=\"inline-block bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg transition-colors\">Try Again</a></div></div>") 46 + if templ_7745c5c3_Err != nil { 47 + return templ_7745c5c3_Err 48 + } 49 + return nil 50 + }) 51 + } 52 + 53 + var _ = templruntime.GeneratedTemplate
+15 -1
docker-bake.hcl
··· 12 12 variable "UBUNTU_VERSION" { default = "24.04" } 13 13 14 14 group "default" { 15 - targets = [ "patreon-saasproxy", "xesite", "github-sponsor-webhook" ] 15 + targets = [ "patreon-saasproxy", "xesite", "github-sponsor-webhook", "sponsor-panel" ] 16 16 } 17 17 18 18 target "patreon-saasproxy" { ··· 63 63 pull = true 64 64 tags = [ 65 65 "registry.int.xeserv.us/xe/site/github-sponsor-webhook:main" 66 + ] 67 + } 68 + 69 + target "sponsor-panel" { 70 + args = { 71 + ALPINE_VERSION = null 72 + GO_VERSION = null 73 + } 74 + context = "." 75 + dockerfile = "./docker/sponsor-panel.Dockerfile" 76 + platforms = [ "linux/amd64", "linux/arm64" ] 77 + pull = true 78 + tags = [ 79 + "registry.int.xeserv.us/xe/site/sponsor-panel:main" 66 80 ] 67 81 }
+35
docker/sponsor-panel.Dockerfile
··· 1 + ARG GO_VERSION=1.25 2 + ARG ALPINE_VERSION=edge 3 + 4 + FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine AS build 5 + 6 + ARG TARGETOS 7 + ARG TARGETARCH 8 + 9 + WORKDIR /app 10 + 11 + COPY go.mod go.sum ./ 12 + RUN go mod download 13 + 14 + COPY . . 15 + RUN apk -U add nodejs npm \ 16 + && npm ci \ 17 + && cd ./cmd/sponsor-panel \ 18 + && go generate ./... 19 + 20 + RUN --mount=type=cache,target=/root/.cache GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /app/bin/sponsor-panel ./cmd/sponsor-panel 21 + 22 + FROM alpine:${ALPINE_VERSION} AS run 23 + WORKDIR /app 24 + 25 + RUN apk add --no-cache ca-certificates 26 + 27 + COPY --from=build /app/bin/sponsor-panel /app/bin/sponsor-panel 28 + 29 + EXPOSE 4823 30 + 31 + CMD ["/app/bin/sponsor-panel"] 32 + 33 + LABEL org.opencontainers.image.source="https://github.com/Xe/site" 34 + LABEL org.opencontainers.image.title="Sponsor Panel Service" 35 + LABEL org.opencontainers.image.description="Web panel for GitHub sponsors to manage their benefits"
+77 -4
go.mod
··· 3 3 go 1.25.5 4 4 5 5 require ( 6 + github.com/a-h/templ v0.3.865 6 7 github.com/aws/aws-sdk-go-v2 v1.41.1 8 + github.com/aws/aws-sdk-go-v2/config v1.29.5 7 9 github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 8 10 github.com/bep/debounce v1.2.1 9 11 github.com/donatj/hmacsig v1.1.0 10 12 github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 11 13 github.com/go-faker/faker/v4 v4.7.0 12 14 github.com/go-git/go-git/v5 v5.16.4 15 + github.com/google/go-github/v82 v82.0.0 13 16 github.com/google/subcommands v1.2.0 17 + github.com/google/uuid v1.6.0 18 + github.com/gorilla/sessions v1.4.0 19 + github.com/jackc/pgx/v5 v5.6.0 14 20 github.com/joho/godotenv v1.5.1 15 21 github.com/prometheus/client_golang v1.23.2 16 22 github.com/stretchr/testify v1.11.1 ··· 28 34 ) 29 35 30 36 require ( 37 + al.essio.dev/pkg/shellescape v1.6.0 // indirect 38 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251120230642-dcccabe2cd63 // indirect 31 39 dario.cat/mergo v1.0.2 // indirect 40 + github.com/AlekSi/pointer v1.2.0 // indirect 41 + github.com/BurntSushi/toml v1.5.0 // indirect 42 + github.com/Masterminds/goutils v1.1.1 // indirect 43 + github.com/Masterminds/semver/v3 v3.4.0 // indirect 44 + github.com/Masterminds/sprig/v3 v3.3.0 // indirect 32 45 github.com/Microsoft/go-winio v0.6.2 // indirect 33 46 github.com/ProtonMail/go-crypto v1.3.0 // indirect 47 + github.com/Songmu/gitconfig v0.2.1 // indirect 48 + github.com/TecharoHQ/yeet v0.8.1 // indirect 49 + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect 50 + github.com/andybalholm/brotli v1.1.1 // indirect 34 51 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 35 - github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect 36 52 github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect 37 53 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 38 54 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect ··· 48 64 github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect 49 65 github.com/aws/smithy-go v1.24.0 // indirect 50 66 github.com/beorn7/perks v1.0.1 // indirect 67 + github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect 68 + github.com/caarlos0/log v0.5.3 // indirect 69 + github.com/caarlos0/pinata v0.3.4 // indirect 70 + github.com/cavaliergopher/cpio v1.0.1 // indirect 71 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 51 72 github.com/cespare/xxhash/v2 v2.3.0 // indirect 73 + github.com/charmbracelet/colorprofile v0.3.3 // indirect 74 + github.com/charmbracelet/ultraviolet v0.0.0-20251120225753-26363bddd922 // indirect 75 + github.com/charmbracelet/x/ansi v0.11.1 // indirect 76 + github.com/charmbracelet/x/term v0.2.2 // indirect 77 + github.com/charmbracelet/x/termios v0.1.1 // indirect 78 + github.com/charmbracelet/x/windows v0.2.2 // indirect 79 + github.com/cli/browser v1.3.0 // indirect 80 + github.com/cli/go-gh/v2 v2.12.1 // indirect 81 + github.com/cli/safeexec v1.0.1 // indirect 82 + github.com/clipperhouse/displaywidth v0.5.0 // indirect 83 + github.com/clipperhouse/stringish v0.1.1 // indirect 84 + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 52 85 github.com/cloudflare/circl v1.6.1 // indirect 53 86 github.com/cyphar/filepath-securejoin v0.6.1 // indirect 54 87 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 88 + github.com/dlclark/regexp2 v1.11.4 // indirect 89 + github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c // indirect 55 90 github.com/emicklei/go-restful/v3 v3.12.2 // indirect 56 91 github.com/emirpasic/gods v1.18.1 // indirect 57 92 github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect 58 93 github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect 94 + github.com/fatih/color v1.18.0 // indirect 95 + github.com/fsnotify/fsnotify v1.9.0 // indirect 59 96 github.com/fxamacker/cbor/v2 v2.9.0 // indirect 60 97 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 61 98 github.com/go-git/go-billy/v5 v5.6.2 // indirect ··· 63 100 github.com/go-openapi/jsonpointer v0.21.0 // indirect 64 101 github.com/go-openapi/jsonreference v0.21.0 // indirect 65 102 github.com/go-openapi/swag v0.23.0 // indirect 103 + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect 104 + github.com/gobwas/glob v0.2.3 // indirect 105 + github.com/goccy/go-yaml v1.18.0 // indirect 66 106 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 67 107 github.com/google/gnostic-models v0.7.0 // indirect 68 - github.com/google/uuid v1.6.0 // indirect 108 + github.com/google/go-querystring v1.2.0 // indirect 109 + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect 110 + github.com/google/rpmpack v0.7.1 // indirect 111 + github.com/goreleaser/chglog v0.7.3 // indirect 112 + github.com/goreleaser/fileglob v1.4.0 // indirect 113 + github.com/goreleaser/nfpm/v2 v2.43.4 // indirect 114 + github.com/gorilla/securecookie v1.1.2 // indirect 115 + github.com/huandu/xstrings v1.5.0 // indirect 69 116 github.com/jackc/pgpassfile v1.0.0 // indirect 70 117 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 71 - github.com/jackc/pgx/v5 v5.6.0 // indirect 72 118 github.com/jackc/puddle/v2 v2.2.2 // indirect 73 119 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 74 120 github.com/jinzhu/inflection v1.0.0 // indirect ··· 76 122 github.com/josharian/intern v1.0.0 // indirect 77 123 github.com/json-iterator/go v1.1.12 // indirect 78 124 github.com/kevinburke/ssh_config v1.2.0 // indirect 125 + github.com/klauspost/compress v1.18.2 // indirect 126 + github.com/klauspost/pgzip v1.2.6 // indirect 127 + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 79 128 github.com/mailru/easyjson v0.7.7 // indirect 129 + github.com/mattn/go-colorable v0.1.13 // indirect 130 + github.com/mattn/go-isatty v0.0.20 // indirect 131 + github.com/mattn/go-runewidth v0.0.19 // indirect 132 + github.com/mitchellh/copystructure v1.2.0 // indirect 133 + github.com/mitchellh/reflectwalk v1.0.2 // indirect 80 134 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 81 135 github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 136 + github.com/muesli/cancelreader v0.2.2 // indirect 82 137 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 138 + github.com/natefinch/atomic v1.0.1 // indirect 83 139 github.com/pjbgf/sha1cd v0.3.2 // indirect 140 + github.com/pkg/errors v0.9.1 // indirect 84 141 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 85 142 github.com/prometheus/client_model v0.6.2 // indirect 86 143 github.com/prometheus/common v0.66.1 // indirect 87 144 github.com/prometheus/procfs v0.16.1 // indirect 145 + github.com/rivo/uniseg v0.4.7 // indirect 88 146 github.com/sergi/go-diff v1.4.0 // indirect 147 + github.com/shopspring/decimal v1.4.0 // indirect 89 148 github.com/skeema/knownhosts v1.3.1 // indirect 149 + github.com/spf13/cast v1.7.1 // indirect 90 150 github.com/spf13/pflag v1.0.9 // indirect 151 + github.com/ulikunitz/xz v0.5.15 // indirect 91 152 github.com/x448/float16 v0.8.4 // indirect 92 153 github.com/xanzy/ssh-agent v0.3.3 // indirect 154 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 155 + gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect 93 156 go.yaml.in/yaml/v2 v2.4.3 // indirect 94 157 go.yaml.in/yaml/v3 v3.0.4 // indirect 95 158 go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 96 159 golang.org/x/crypto v0.46.0 // indirect 160 + golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect 97 161 golang.org/x/mod v0.30.0 // indirect 98 162 golang.org/x/net v0.48.0 // indirect 99 163 golang.org/x/sync v0.19.0 // indirect ··· 107 171 gopkg.in/inf.v0 v0.9.1 // indirect 108 172 gopkg.in/warnings.v0 v0.1.2 // indirect 109 173 gopkg.in/yaml.v3 v3.0.1 // indirect 174 + honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 // indirect 110 175 k8s.io/api v0.35.0 // indirect 111 176 k8s.io/klog/v2 v2.130.1 // indirect 112 177 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect 113 178 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect 179 + mvdan.cc/sh/v3 v3.12.0 // indirect 114 180 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect 115 181 sigs.k8s.io/randfill v1.0.0 // indirect 116 182 sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 117 183 sigs.k8s.io/yaml v1.6.0 // indirect 118 184 ) 119 185 120 - tool golang.org/x/tools/cmd/goimports 186 + tool ( 187 + github.com/TecharoHQ/yeet/cmd/yeet 188 + github.com/a-h/templ/cmd/templ 189 + github.com/caarlos0/pinata 190 + golang.org/x/tools/cmd/goimports 191 + golang.org/x/tools/cmd/stringer 192 + honnef.co/go/tools/cmd/staticcheck 193 + )
+184 -1
go.sum
··· 1 + al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= 2 + al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251120230642-dcccabe2cd63 h1:KgI+p678truaonNOQek4i+aJdWAtdpvFzz5lqHBaDeI= 4 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251120230642-dcccabe2cd63/go.mod h1:bjsp2D+VGi56y8f53S7xCphcoqJb36vo3dBVh0RrpP8= 1 5 dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= 2 6 dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 3 - github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 7 + github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= 8 + github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= 9 + github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 10 + github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 11 + github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= 12 + github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= 13 + github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 14 + github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= 15 + github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= 16 + github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 17 + github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 4 18 github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 5 19 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 20 + github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 21 + github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 6 22 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 7 23 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 8 24 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 9 25 github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 10 26 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 27 + github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= 28 + github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= 29 + github.com/ProtonMail/gopenpgp/v2 v2.7.1 h1:Awsg7MPc2gD3I7IFac2qE3Gdls0lZW8SzrFZ3k1oz0s= 30 + github.com/ProtonMail/gopenpgp/v2 v2.7.1/go.mod h1:/BU5gfAVwqyd8EfC3Eu7zmuhwYQpKs+cGD8M//iiaxs= 31 + github.com/ProtonMail/gopenpgp/v3 v3.3.0 h1:N6rHCH5PWwB6zSRMgRj1EbAMQHUAAHxH3Oo4KibsPwY= 32 + github.com/ProtonMail/gopenpgp/v3 v3.3.0/go.mod h1:J+iNPt0/5EO9wRt7Eit9dRUlzyu3hiGX3zId6iuaKOk= 33 + github.com/Songmu/gitconfig v0.2.1 h1:cZsqELfMtxWVI8ovq17gbvsR4qLfoYLAiXy5GwtJWbk= 34 + github.com/Songmu/gitconfig v0.2.1/go.mod h1:XM4O3SoXFnli9Ql2G7qXK2Fg7LJwf7Hs8GLFEOJlzmM= 35 + github.com/TecharoHQ/yeet v0.8.1 h1:wriCSlyIDIZKRPmrf6ZXZx7uiAdH+OvshNQkwW+hNw8= 36 + github.com/TecharoHQ/yeet v0.8.1/go.mod h1:PLlw/OTk9fCvobE5O0I7cIaxKuuB9A46yrEihTFHA9c= 37 + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= 38 + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= 39 + github.com/a-h/templ v0.3.865 h1:nYn5EWm9EiXaDgWcMQaKiKvrydqgxDUtT1+4zU2C43A= 40 + github.com/a-h/templ v0.3.865/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ= 41 + github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 42 + github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 11 43 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 12 44 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 13 45 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= ··· 48 80 github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= 49 81 github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 50 82 github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 83 + github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 84 + github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 51 85 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 52 86 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 53 87 github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= 54 88 github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 89 + github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= 90 + github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= 91 + github.com/caarlos0/log v0.5.3 h1:ocxCwZWjI9u9eNFMnEJN2OzS7FXkfaKxg5afC4uOltc= 92 + github.com/caarlos0/log v0.5.3/go.mod h1:n6BAWZiYv9obEy89jSNAmBo0m3x93chRBElXhyqpg4c= 93 + github.com/caarlos0/pinata v0.3.4 h1:7EJ6rs8oFn1EMW7QNffOSlC6sgrZ2siKCC/4RHWRVrU= 94 + github.com/caarlos0/pinata v0.3.4/go.mod h1:DSlSMAkoL1uJZ1Hb24s72sMTu+WlJhQ30nUjSUmqZAE= 95 + github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= 96 + github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= 97 + github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= 98 + github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= 99 + github.com/cavaliergopher/rpm v1.3.0 h1:UHX46sasX8MesUXXQ+UbkFLUX4eUWTlEcX8jcnRBIgI= 100 + github.com/cavaliergopher/rpm v1.3.0/go.mod h1:vEumo1vvtrHM1Ov86f6+k8j7zNKOxQfHDCAIcR/36ZI= 101 + github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 102 + github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 55 103 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 56 104 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 105 + github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= 106 + github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= 107 + github.com/charmbracelet/ultraviolet v0.0.0-20251120225753-26363bddd922 h1:xT8rVR6RTBTx90ViTIrB766UNC9wnGzgkrfO6UCsiZ0= 108 + github.com/charmbracelet/ultraviolet v0.0.0-20251120225753-26363bddd922/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc= 109 + github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk= 110 + github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0= 111 + github.com/charmbracelet/x/exp/golden v0.0.0-20250916153604-9a2e892ed98e h1:Nn4cZqlyqrC3OOrcxRSbPt12Wvfp4DvM6RsKIZ0vKcw= 112 + github.com/charmbracelet/x/exp/golden v0.0.0-20250916153604-9a2e892ed98e/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw= 113 + github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 114 + github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 115 + github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 116 + github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 117 + github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= 118 + github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= 119 + github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= 120 + github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= 121 + github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA= 122 + github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw= 123 + github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= 124 + github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= 125 + github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I= 126 + github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= 127 + github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 128 + github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 129 + github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= 130 + github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 57 131 github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 58 132 github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 133 + github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 134 + github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 59 135 github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= 60 136 github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= 61 137 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 62 138 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 63 139 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 64 140 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 141 + github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= 142 + github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 65 143 github.com/donatj/hmacsig v1.1.0 h1:DbBIW1ZTMfJoJhDGPVpkatYyxhrR2xVoHAokPTrlw50= 66 144 github.com/donatj/hmacsig v1.1.0/go.mod h1:rh/7q72Fo5oYc7bcKgvGHWsfHcs8jKhJdFgCZcvZ/G0= 145 + github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c h1:mxWGS0YyquJ/ikZOjSrRjjFIbUqIP9ojyYQ+QZTU3Rg= 146 + github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= 67 147 github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 68 148 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 69 149 github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= ··· 78 158 github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= 79 159 github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk= 80 160 github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= 161 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 162 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 163 + github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 164 + github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 81 165 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 82 166 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 83 167 github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= ··· 104 188 github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 105 189 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 106 190 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 191 + github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= 192 + github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 193 + github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= 194 + github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 107 195 github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 108 196 github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 197 + github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 198 + github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 199 + github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 200 + github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 109 201 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 110 202 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 111 203 github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= 112 204 github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 205 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 113 206 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 114 207 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 208 + github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= 209 + github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= 210 + github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= 211 + github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= 115 212 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 213 + github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 214 + github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 116 215 github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= 117 216 github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 217 + github.com/google/rpmpack v0.7.1 h1:YdWh1IpzOjBz60Wvdw0TU0A5NWP+JTVHA5poDqwMO2o= 218 + github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= 219 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 220 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 118 221 github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= 119 222 github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= 120 223 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 121 224 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 225 + github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 226 + github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 227 + github.com/goreleaser/chglog v0.7.3 h1:eCKJrvsDgG+F1F2fhwM6qX+S5yMiZgsQ4VNTPFl9qEM= 228 + github.com/goreleaser/chglog v0.7.3/go.mod h1:HXPf4avc1kTD00a46LuTEH0i1dZctLq8Xs2BxUfROnY= 229 + github.com/goreleaser/fileglob v1.4.0 h1:Y7zcUnzQjT1gbntacGAkIIfLv+OwojxTXBFxjSFoBBs= 230 + github.com/goreleaser/fileglob v1.4.0/go.mod h1:1pbHx7hhmJIxNZvm6fi6WVrnP0tndq6p3ayWdLn1Yf8= 231 + github.com/goreleaser/nfpm/v2 v2.43.4 h1:1gJ+V2CA21YxvJd+SSE+E0C5g8wuGHimkYK1txC5hKQ= 232 + github.com/goreleaser/nfpm/v2 v2.43.4/go.mod h1:gj1p1m05X1WZ/6tjngIaEPGa5H+kmhkpSId2Tz9lX3g= 233 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 234 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 235 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 236 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 237 + github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 238 + github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 122 239 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 123 240 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 124 241 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= ··· 139 256 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 140 257 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 141 258 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 259 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 260 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 142 261 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 143 262 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 263 + github.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d h1:RnWZeH8N8KXfbwMTex/KKMYMj0FJRCF6tQubUuQ02GM= 264 + github.com/kjk/lzma v0.0.0-20161016003348-3fd93898850d/go.mod h1:phT/jsRPBAEqjAibu1BurrabCBNTYiVI+zbmyCZJY6Q= 144 265 github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= 145 266 github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 267 + github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= 268 + github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= 146 269 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 147 270 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 148 271 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= ··· 152 275 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 153 276 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 154 277 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 278 + github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 279 + github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 155 280 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 156 281 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 282 + github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 283 + github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 284 + github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 285 + github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 286 + github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 287 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 288 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 289 + github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 290 + github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 291 + github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 292 + github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 293 + github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 294 + github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 157 295 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 158 296 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 159 297 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 160 298 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 161 299 github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= 162 300 github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 301 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 302 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 163 303 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 164 304 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 305 + github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= 306 + github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= 165 307 github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= 166 308 github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= 167 309 github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= ··· 181 323 github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 182 324 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 183 325 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 326 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 327 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 184 328 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 185 329 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 330 + github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg= 331 + github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= 186 332 github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= 187 333 github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 334 + github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 335 + github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 188 336 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 189 337 github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 190 338 github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 339 + github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= 340 + github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= 341 + github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 342 + github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 343 + github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 344 + github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 191 345 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 192 346 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 193 347 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= ··· 201 355 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 202 356 github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= 203 357 github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= 358 + github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= 359 + github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 204 360 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 205 361 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 206 362 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 207 363 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 364 + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= 365 + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= 366 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 367 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 368 + github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 369 + github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 370 + gitlab.alpinelinux.org/alpine/go v0.10.1 h1:QoidnfDyC9yeIMj+CvYVyjlroZD/Kl7JRXGEQBvY5XM= 371 + gitlab.alpinelinux.org/alpine/go v0.10.1/go.mod h1:zwds+1zTmPDgwf/9lOzzn+oZVBr6jyfVgH3zuwkfkzc= 372 + gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= 373 + gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= 208 374 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 209 375 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 210 376 go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= ··· 218 384 golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 219 385 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 220 386 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 387 + golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= 388 + golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 221 389 golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= 222 390 golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 223 391 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= ··· 233 401 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 402 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 235 403 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 404 + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 405 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 236 406 golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= 237 407 golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 238 408 golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo= ··· 248 418 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 249 419 golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= 250 420 golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 421 + golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= 422 + golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= 251 423 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 252 424 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 253 425 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= ··· 260 432 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 261 433 gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 262 434 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 435 + gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 436 + gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 263 437 gopkg.in/mxpv/patreon-go.v1 v1.0.0-20171031001022-1d2f253ac700 h1:ymnLBRNALxuok6al+nlPJxfSa3yc2SZc5N21svHQtys= 264 438 gopkg.in/mxpv/patreon-go.v1 v1.0.0-20171031001022-1d2f253ac700/go.mod h1:IZaw6NfbSsGszLfPbo9LLlxLIx17eMHWe4cxpM8wUMk= 265 439 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 266 440 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 267 441 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 442 + gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 268 443 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 269 444 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 270 445 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= ··· 273 448 gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= 274 449 gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= 275 450 gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 451 + honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho= 452 + honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ= 276 453 k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= 277 454 k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= 278 455 k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= ··· 285 462 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= 286 463 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= 287 464 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 465 + mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI= 466 + mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg= 467 + pault.ag/go/debian v0.19.0 h1:RUxCjScMbnlqFH5I+qsmyjZH8fXXtQ05rlkMJop3tjo= 468 + pault.ag/go/debian v0.19.0/go.mod h1:1LMojDAazlJ7cA5Ne6H2ZHD4hh3o8NRiW+MpvQRji2o= 469 + pault.ag/go/topsort v0.1.1 h1:L0QnhUly6LmTv0e3DEzbN2q6/FGgAcQvaEw65S53Bg4= 470 + pault.ag/go/topsort v0.1.1/go.mod h1:r1kc/L0/FZ3HhjezBIPaNVhkqv8L0UJ9bxRuHRVZ0q4= 288 471 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= 289 472 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 290 473 sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+5 -6
internal/adminpb/internal.pb.go
··· 1 1 // Code generated by protoc-gen-go. DO NOT EDIT. 2 2 // versions: 3 - // protoc-gen-go v1.36.10 4 - // protoc v6.33.0 3 + // protoc-gen-go v1.36.11 4 + // protoc v6.33.4 5 5 // source: internal.proto 6 6 7 7 package adminpb 8 8 9 9 import ( 10 - reflect "reflect" 11 - sync "sync" 12 - unsafe "unsafe" 13 - 14 10 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 15 11 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 16 12 emptypb "google.golang.org/protobuf/types/known/emptypb" 17 13 timestamppb "google.golang.org/protobuf/types/known/timestamppb" 14 + reflect "reflect" 15 + sync "sync" 16 + unsafe "unsafe" 18 17 pb "xeiaso.net/v4/pb" 19 18 ) 20 19
+17 -31
internal/adminpb/internal.twirp.go
··· 3 3 4 4 package adminpb 5 5 6 - import ( 7 - context "context" 8 - fmt "fmt" 9 - 10 - http "net/http" 11 - 12 - io "io" 13 - 14 - json "encoding/json" 15 - 16 - strconv "strconv" 17 - 18 - strings "strings" 19 - 20 - protojson "google.golang.org/protobuf/encoding/protojson" 21 - 22 - proto "google.golang.org/protobuf/proto" 23 - 24 - twirp "github.com/twitchtv/twirp" 25 - 26 - ctxsetters "github.com/twitchtv/twirp/ctxsetters" 27 - 28 - google_protobuf "google.golang.org/protobuf/types/known/emptypb" 29 - 30 - xeiaso_net "xeiaso.net/v4/pb" 31 - 32 - bytes "bytes" 6 + import context "context" 7 + import fmt "fmt" 8 + import http "net/http" 9 + import io "io" 10 + import json "encoding/json" 11 + import strconv "strconv" 12 + import strings "strings" 33 13 34 - errors "errors" 14 + import protojson "google.golang.org/protobuf/encoding/protojson" 15 + import proto "google.golang.org/protobuf/proto" 16 + import twirp "github.com/twitchtv/twirp" 17 + import ctxsetters "github.com/twitchtv/twirp/ctxsetters" 35 18 36 - path "path" 19 + import google_protobuf "google.golang.org/protobuf/types/known/emptypb" 20 + import xeiaso_net "xeiaso.net/v4/pb" 37 21 38 - url "net/url" 39 - ) 22 + import bytes "bytes" 23 + import errors "errors" 24 + import path "path" 25 + import url "net/url" 40 26 41 27 // Version compatibility assertion. 42 28 // If the constant is not defined in the package, that likely means
+6
manifest/sponsor-panel/1password.yaml
··· 1 + apiVersion: onepassword.com/v1 2 + kind: OnePasswordItem 3 + metadata: 4 + name: sponsor-panel-creds 5 + spec: 6 + itemPath: "vaults/lc5zo4zjz3if3mkeuhufjmgmui/items/Sponsor Panel Creds"
+10
manifest/sponsor-panel/database.yaml
··· 1 + apiVersion: postgresql.cnpg.io/v1 2 + kind: Cluster 3 + metadata: 4 + name: sponsor-panel 5 + spec: 6 + instances: 2 7 + enableSuperuserAccess: true 8 + 9 + storage: 10 + size: 32Gi
+56
manifest/sponsor-panel/deployment.yaml
··· 1 + apiVersion: apps/v1 2 + kind: Deployment 3 + metadata: 4 + name: sponsor-panel 5 + labels: 6 + app.kubernetes.io/name: sponsor-panel 7 + app.kubernetes.io/component: web 8 + annotations: 9 + keel.sh/policy: all 10 + keel.sh/trigger: poll 11 + keel.sh/pollSchedule: "@hourly" 12 + spec: 13 + replicas: 3 14 + selector: 15 + matchLabels: 16 + app.kubernetes.io/name: sponsor-panel 17 + template: 18 + metadata: 19 + labels: 20 + app.kubernetes.io/name: sponsor-panel 21 + app.kubernetes.io/component: web 22 + spec: 23 + containers: 24 + - name: web 25 + image: reg.xeiaso.net/xe/site/sponsor-panel:main 26 + imagePullPolicy: Always 27 + resources: 28 + limits: 29 + memory: "256Mi" 30 + cpu: "500m" 31 + requests: 32 + memory: "128Mi" 33 + cpu: "100m" 34 + env: 35 + - name: BIND 36 + value: ":4823" 37 + - name: SLOG_LEVEL 38 + value: "info" 39 + ports: 40 + - containerPort: 4823 41 + name: http 42 + livenessProbe: 43 + httpGet: 44 + path: /health 45 + port: 4823 46 + initialDelaySeconds: 3 47 + periodSeconds: 3 48 + readinessProbe: 49 + httpGet: 50 + path: /health 51 + port: 4823 52 + initialDelaySeconds: 1 53 + periodSeconds: 3 54 + envFrom: 55 + - secretRef: 56 + name: sponsor-panel-creds
+24
manifest/sponsor-panel/ingress.yaml
··· 1 + apiVersion: networking.k8s.io/v1 2 + kind: Ingress 3 + metadata: 4 + name: sponsor-panel 5 + annotations: 6 + cert-manager.io/cluster-issuer: "letsencrypt-prod" 7 + nginx.ingress.kubernetes.io/ssl-redirect: "true" 8 + spec: 9 + ingressClassName: nginx 10 + tls: 11 + - hosts: 12 + - sponsors.xeiaso.net 13 + secretName: sponsors-xeiaso-net-tls 14 + rules: 15 + - host: sponsors.xeiaso.net 16 + http: 17 + paths: 18 + - path: / 19 + pathType: Prefix 20 + backend: 21 + service: 22 + name: sponsor-panel 23 + port: 24 + number: 80
+11
manifest/sponsor-panel/kustomization.yaml
··· 1 + resources: 2 + - 1password.yaml 3 + #- database.yaml 4 + - deployment.yaml 5 + - ingress.yaml 6 + - service.yaml 7 + 8 + namespace: default 9 + 10 + commonLabels: 11 + app.kubernetes.io/name: sponsor-panel
+14
manifest/sponsor-panel/service.yaml
··· 1 + apiVersion: v1 2 + kind: Service 3 + metadata: 4 + name: sponsor-panel 5 + labels: 6 + app.kubernetes.io/name: sponsor-panel 7 + spec: 8 + selector: 9 + app.kubernetes.io/name: sponsor-panel 10 + ports: 11 + - port: 80 12 + targetPort: 4823 13 + protocol: TCP 14 + name: http
+1053 -1
package-lock.json
··· 9 9 "version": "4.0.0", 10 10 "license": "ISC", 11 11 "dependencies": { 12 + "@tailwindcss/cli": "^4.1.18", 12 13 "execa": "^8.0.1", 13 14 "jsdom": "^25.0.0" 14 15 }, 15 16 "devDependencies": { 16 - "gray-matter": "^4.0.3" 17 + "@tailwindcss/forms": "^0.5.7", 18 + "@tailwindcss/typography": "^0.5.14", 19 + "gray-matter": "^4.0.3", 20 + "tailwindcss": "^4.1.18" 17 21 } 18 22 }, 19 23 "node_modules/@asamuzakjp/css-color": { ··· 139 143 "node": ">=18" 140 144 } 141 145 }, 146 + "node_modules/@jridgewell/gen-mapping": { 147 + "version": "0.3.13", 148 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", 149 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", 150 + "license": "MIT", 151 + "dependencies": { 152 + "@jridgewell/sourcemap-codec": "^1.5.0", 153 + "@jridgewell/trace-mapping": "^0.3.24" 154 + } 155 + }, 156 + "node_modules/@jridgewell/remapping": { 157 + "version": "2.3.5", 158 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", 159 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", 160 + "license": "MIT", 161 + "dependencies": { 162 + "@jridgewell/gen-mapping": "^0.3.5", 163 + "@jridgewell/trace-mapping": "^0.3.24" 164 + } 165 + }, 166 + "node_modules/@jridgewell/resolve-uri": { 167 + "version": "3.1.2", 168 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 169 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 170 + "license": "MIT", 171 + "engines": { 172 + "node": ">=6.0.0" 173 + } 174 + }, 175 + "node_modules/@jridgewell/sourcemap-codec": { 176 + "version": "1.5.5", 177 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 178 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 179 + "license": "MIT" 180 + }, 181 + "node_modules/@jridgewell/trace-mapping": { 182 + "version": "0.3.31", 183 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", 184 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", 185 + "license": "MIT", 186 + "dependencies": { 187 + "@jridgewell/resolve-uri": "^3.1.0", 188 + "@jridgewell/sourcemap-codec": "^1.4.14" 189 + } 190 + }, 191 + "node_modules/@parcel/watcher": { 192 + "version": "2.5.6", 193 + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", 194 + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", 195 + "hasInstallScript": true, 196 + "license": "MIT", 197 + "dependencies": { 198 + "detect-libc": "^2.0.3", 199 + "is-glob": "^4.0.3", 200 + "node-addon-api": "^7.0.0", 201 + "picomatch": "^4.0.3" 202 + }, 203 + "engines": { 204 + "node": ">= 10.0.0" 205 + }, 206 + "funding": { 207 + "type": "opencollective", 208 + "url": "https://opencollective.com/parcel" 209 + }, 210 + "optionalDependencies": { 211 + "@parcel/watcher-android-arm64": "2.5.6", 212 + "@parcel/watcher-darwin-arm64": "2.5.6", 213 + "@parcel/watcher-darwin-x64": "2.5.6", 214 + "@parcel/watcher-freebsd-x64": "2.5.6", 215 + "@parcel/watcher-linux-arm-glibc": "2.5.6", 216 + "@parcel/watcher-linux-arm-musl": "2.5.6", 217 + "@parcel/watcher-linux-arm64-glibc": "2.5.6", 218 + "@parcel/watcher-linux-arm64-musl": "2.5.6", 219 + "@parcel/watcher-linux-x64-glibc": "2.5.6", 220 + "@parcel/watcher-linux-x64-musl": "2.5.6", 221 + "@parcel/watcher-win32-arm64": "2.5.6", 222 + "@parcel/watcher-win32-ia32": "2.5.6", 223 + "@parcel/watcher-win32-x64": "2.5.6" 224 + } 225 + }, 226 + "node_modules/@parcel/watcher-android-arm64": { 227 + "version": "2.5.6", 228 + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", 229 + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", 230 + "cpu": [ 231 + "arm64" 232 + ], 233 + "license": "MIT", 234 + "optional": true, 235 + "os": [ 236 + "android" 237 + ], 238 + "engines": { 239 + "node": ">= 10.0.0" 240 + }, 241 + "funding": { 242 + "type": "opencollective", 243 + "url": "https://opencollective.com/parcel" 244 + } 245 + }, 246 + "node_modules/@parcel/watcher-darwin-arm64": { 247 + "version": "2.5.6", 248 + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", 249 + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", 250 + "cpu": [ 251 + "arm64" 252 + ], 253 + "license": "MIT", 254 + "optional": true, 255 + "os": [ 256 + "darwin" 257 + ], 258 + "engines": { 259 + "node": ">= 10.0.0" 260 + }, 261 + "funding": { 262 + "type": "opencollective", 263 + "url": "https://opencollective.com/parcel" 264 + } 265 + }, 266 + "node_modules/@parcel/watcher-darwin-x64": { 267 + "version": "2.5.6", 268 + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", 269 + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", 270 + "cpu": [ 271 + "x64" 272 + ], 273 + "license": "MIT", 274 + "optional": true, 275 + "os": [ 276 + "darwin" 277 + ], 278 + "engines": { 279 + "node": ">= 10.0.0" 280 + }, 281 + "funding": { 282 + "type": "opencollective", 283 + "url": "https://opencollective.com/parcel" 284 + } 285 + }, 286 + "node_modules/@parcel/watcher-freebsd-x64": { 287 + "version": "2.5.6", 288 + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", 289 + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", 290 + "cpu": [ 291 + "x64" 292 + ], 293 + "license": "MIT", 294 + "optional": true, 295 + "os": [ 296 + "freebsd" 297 + ], 298 + "engines": { 299 + "node": ">= 10.0.0" 300 + }, 301 + "funding": { 302 + "type": "opencollective", 303 + "url": "https://opencollective.com/parcel" 304 + } 305 + }, 306 + "node_modules/@parcel/watcher-linux-arm-glibc": { 307 + "version": "2.5.6", 308 + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", 309 + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", 310 + "cpu": [ 311 + "arm" 312 + ], 313 + "license": "MIT", 314 + "optional": true, 315 + "os": [ 316 + "linux" 317 + ], 318 + "engines": { 319 + "node": ">= 10.0.0" 320 + }, 321 + "funding": { 322 + "type": "opencollective", 323 + "url": "https://opencollective.com/parcel" 324 + } 325 + }, 326 + "node_modules/@parcel/watcher-linux-arm-musl": { 327 + "version": "2.5.6", 328 + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", 329 + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", 330 + "cpu": [ 331 + "arm" 332 + ], 333 + "license": "MIT", 334 + "optional": true, 335 + "os": [ 336 + "linux" 337 + ], 338 + "engines": { 339 + "node": ">= 10.0.0" 340 + }, 341 + "funding": { 342 + "type": "opencollective", 343 + "url": "https://opencollective.com/parcel" 344 + } 345 + }, 346 + "node_modules/@parcel/watcher-linux-arm64-glibc": { 347 + "version": "2.5.6", 348 + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", 349 + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", 350 + "cpu": [ 351 + "arm64" 352 + ], 353 + "license": "MIT", 354 + "optional": true, 355 + "os": [ 356 + "linux" 357 + ], 358 + "engines": { 359 + "node": ">= 10.0.0" 360 + }, 361 + "funding": { 362 + "type": "opencollective", 363 + "url": "https://opencollective.com/parcel" 364 + } 365 + }, 366 + "node_modules/@parcel/watcher-linux-arm64-musl": { 367 + "version": "2.5.6", 368 + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", 369 + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", 370 + "cpu": [ 371 + "arm64" 372 + ], 373 + "license": "MIT", 374 + "optional": true, 375 + "os": [ 376 + "linux" 377 + ], 378 + "engines": { 379 + "node": ">= 10.0.0" 380 + }, 381 + "funding": { 382 + "type": "opencollective", 383 + "url": "https://opencollective.com/parcel" 384 + } 385 + }, 386 + "node_modules/@parcel/watcher-linux-x64-glibc": { 387 + "version": "2.5.6", 388 + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", 389 + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", 390 + "cpu": [ 391 + "x64" 392 + ], 393 + "license": "MIT", 394 + "optional": true, 395 + "os": [ 396 + "linux" 397 + ], 398 + "engines": { 399 + "node": ">= 10.0.0" 400 + }, 401 + "funding": { 402 + "type": "opencollective", 403 + "url": "https://opencollective.com/parcel" 404 + } 405 + }, 406 + "node_modules/@parcel/watcher-linux-x64-musl": { 407 + "version": "2.5.6", 408 + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", 409 + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", 410 + "cpu": [ 411 + "x64" 412 + ], 413 + "license": "MIT", 414 + "optional": true, 415 + "os": [ 416 + "linux" 417 + ], 418 + "engines": { 419 + "node": ">= 10.0.0" 420 + }, 421 + "funding": { 422 + "type": "opencollective", 423 + "url": "https://opencollective.com/parcel" 424 + } 425 + }, 426 + "node_modules/@parcel/watcher-win32-arm64": { 427 + "version": "2.5.6", 428 + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", 429 + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", 430 + "cpu": [ 431 + "arm64" 432 + ], 433 + "license": "MIT", 434 + "optional": true, 435 + "os": [ 436 + "win32" 437 + ], 438 + "engines": { 439 + "node": ">= 10.0.0" 440 + }, 441 + "funding": { 442 + "type": "opencollective", 443 + "url": "https://opencollective.com/parcel" 444 + } 445 + }, 446 + "node_modules/@parcel/watcher-win32-ia32": { 447 + "version": "2.5.6", 448 + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", 449 + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", 450 + "cpu": [ 451 + "ia32" 452 + ], 453 + "license": "MIT", 454 + "optional": true, 455 + "os": [ 456 + "win32" 457 + ], 458 + "engines": { 459 + "node": ">= 10.0.0" 460 + }, 461 + "funding": { 462 + "type": "opencollective", 463 + "url": "https://opencollective.com/parcel" 464 + } 465 + }, 466 + "node_modules/@parcel/watcher-win32-x64": { 467 + "version": "2.5.6", 468 + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", 469 + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", 470 + "cpu": [ 471 + "x64" 472 + ], 473 + "license": "MIT", 474 + "optional": true, 475 + "os": [ 476 + "win32" 477 + ], 478 + "engines": { 479 + "node": ">= 10.0.0" 480 + }, 481 + "funding": { 482 + "type": "opencollective", 483 + "url": "https://opencollective.com/parcel" 484 + } 485 + }, 486 + "node_modules/@tailwindcss/cli": { 487 + "version": "4.1.18", 488 + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz", 489 + "integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==", 490 + "license": "MIT", 491 + "dependencies": { 492 + "@parcel/watcher": "^2.5.1", 493 + "@tailwindcss/node": "4.1.18", 494 + "@tailwindcss/oxide": "4.1.18", 495 + "enhanced-resolve": "^5.18.3", 496 + "mri": "^1.2.0", 497 + "picocolors": "^1.1.1", 498 + "tailwindcss": "4.1.18" 499 + }, 500 + "bin": { 501 + "tailwindcss": "dist/index.mjs" 502 + } 503 + }, 504 + "node_modules/@tailwindcss/forms": { 505 + "version": "0.5.11", 506 + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", 507 + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", 508 + "dev": true, 509 + "license": "MIT", 510 + "dependencies": { 511 + "mini-svg-data-uri": "^1.2.3" 512 + }, 513 + "peerDependencies": { 514 + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" 515 + } 516 + }, 517 + "node_modules/@tailwindcss/node": { 518 + "version": "4.1.18", 519 + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", 520 + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", 521 + "license": "MIT", 522 + "dependencies": { 523 + "@jridgewell/remapping": "^2.3.4", 524 + "enhanced-resolve": "^5.18.3", 525 + "jiti": "^2.6.1", 526 + "lightningcss": "1.30.2", 527 + "magic-string": "^0.30.21", 528 + "source-map-js": "^1.2.1", 529 + "tailwindcss": "4.1.18" 530 + } 531 + }, 532 + "node_modules/@tailwindcss/oxide": { 533 + "version": "4.1.18", 534 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", 535 + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", 536 + "license": "MIT", 537 + "engines": { 538 + "node": ">= 10" 539 + }, 540 + "optionalDependencies": { 541 + "@tailwindcss/oxide-android-arm64": "4.1.18", 542 + "@tailwindcss/oxide-darwin-arm64": "4.1.18", 543 + "@tailwindcss/oxide-darwin-x64": "4.1.18", 544 + "@tailwindcss/oxide-freebsd-x64": "4.1.18", 545 + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", 546 + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", 547 + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", 548 + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", 549 + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", 550 + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", 551 + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", 552 + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" 553 + } 554 + }, 555 + "node_modules/@tailwindcss/oxide-android-arm64": { 556 + "version": "4.1.18", 557 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", 558 + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", 559 + "cpu": [ 560 + "arm64" 561 + ], 562 + "license": "MIT", 563 + "optional": true, 564 + "os": [ 565 + "android" 566 + ], 567 + "engines": { 568 + "node": ">= 10" 569 + } 570 + }, 571 + "node_modules/@tailwindcss/oxide-darwin-arm64": { 572 + "version": "4.1.18", 573 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", 574 + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", 575 + "cpu": [ 576 + "arm64" 577 + ], 578 + "license": "MIT", 579 + "optional": true, 580 + "os": [ 581 + "darwin" 582 + ], 583 + "engines": { 584 + "node": ">= 10" 585 + } 586 + }, 587 + "node_modules/@tailwindcss/oxide-darwin-x64": { 588 + "version": "4.1.18", 589 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", 590 + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", 591 + "cpu": [ 592 + "x64" 593 + ], 594 + "license": "MIT", 595 + "optional": true, 596 + "os": [ 597 + "darwin" 598 + ], 599 + "engines": { 600 + "node": ">= 10" 601 + } 602 + }, 603 + "node_modules/@tailwindcss/oxide-freebsd-x64": { 604 + "version": "4.1.18", 605 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", 606 + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", 607 + "cpu": [ 608 + "x64" 609 + ], 610 + "license": "MIT", 611 + "optional": true, 612 + "os": [ 613 + "freebsd" 614 + ], 615 + "engines": { 616 + "node": ">= 10" 617 + } 618 + }, 619 + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { 620 + "version": "4.1.18", 621 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", 622 + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", 623 + "cpu": [ 624 + "arm" 625 + ], 626 + "license": "MIT", 627 + "optional": true, 628 + "os": [ 629 + "linux" 630 + ], 631 + "engines": { 632 + "node": ">= 10" 633 + } 634 + }, 635 + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { 636 + "version": "4.1.18", 637 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", 638 + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", 639 + "cpu": [ 640 + "arm64" 641 + ], 642 + "license": "MIT", 643 + "optional": true, 644 + "os": [ 645 + "linux" 646 + ], 647 + "engines": { 648 + "node": ">= 10" 649 + } 650 + }, 651 + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { 652 + "version": "4.1.18", 653 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", 654 + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", 655 + "cpu": [ 656 + "arm64" 657 + ], 658 + "license": "MIT", 659 + "optional": true, 660 + "os": [ 661 + "linux" 662 + ], 663 + "engines": { 664 + "node": ">= 10" 665 + } 666 + }, 667 + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { 668 + "version": "4.1.18", 669 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", 670 + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", 671 + "cpu": [ 672 + "x64" 673 + ], 674 + "license": "MIT", 675 + "optional": true, 676 + "os": [ 677 + "linux" 678 + ], 679 + "engines": { 680 + "node": ">= 10" 681 + } 682 + }, 683 + "node_modules/@tailwindcss/oxide-linux-x64-musl": { 684 + "version": "4.1.18", 685 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", 686 + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", 687 + "cpu": [ 688 + "x64" 689 + ], 690 + "license": "MIT", 691 + "optional": true, 692 + "os": [ 693 + "linux" 694 + ], 695 + "engines": { 696 + "node": ">= 10" 697 + } 698 + }, 699 + "node_modules/@tailwindcss/oxide-wasm32-wasi": { 700 + "version": "4.1.18", 701 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", 702 + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", 703 + "bundleDependencies": [ 704 + "@napi-rs/wasm-runtime", 705 + "@emnapi/core", 706 + "@emnapi/runtime", 707 + "@tybys/wasm-util", 708 + "@emnapi/wasi-threads", 709 + "tslib" 710 + ], 711 + "cpu": [ 712 + "wasm32" 713 + ], 714 + "license": "MIT", 715 + "optional": true, 716 + "dependencies": { 717 + "@emnapi/core": "^1.7.1", 718 + "@emnapi/runtime": "^1.7.1", 719 + "@emnapi/wasi-threads": "^1.1.0", 720 + "@napi-rs/wasm-runtime": "^1.1.0", 721 + "@tybys/wasm-util": "^0.10.1", 722 + "tslib": "^2.4.0" 723 + }, 724 + "engines": { 725 + "node": ">=14.0.0" 726 + } 727 + }, 728 + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { 729 + "version": "4.1.18", 730 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", 731 + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", 732 + "cpu": [ 733 + "arm64" 734 + ], 735 + "license": "MIT", 736 + "optional": true, 737 + "os": [ 738 + "win32" 739 + ], 740 + "engines": { 741 + "node": ">= 10" 742 + } 743 + }, 744 + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { 745 + "version": "4.1.18", 746 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", 747 + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", 748 + "cpu": [ 749 + "x64" 750 + ], 751 + "license": "MIT", 752 + "optional": true, 753 + "os": [ 754 + "win32" 755 + ], 756 + "engines": { 757 + "node": ">= 10" 758 + } 759 + }, 760 + "node_modules/@tailwindcss/typography": { 761 + "version": "0.5.19", 762 + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", 763 + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", 764 + "dev": true, 765 + "license": "MIT", 766 + "dependencies": { 767 + "postcss-selector-parser": "6.0.10" 768 + }, 769 + "peerDependencies": { 770 + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" 771 + } 772 + }, 142 773 "node_modules/agent-base": { 143 774 "version": "7.1.4", 144 775 "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", ··· 202 833 "node": ">= 8" 203 834 } 204 835 }, 836 + "node_modules/cssesc": { 837 + "version": "3.0.0", 838 + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", 839 + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", 840 + "dev": true, 841 + "license": "MIT", 842 + "bin": { 843 + "cssesc": "bin/cssesc" 844 + }, 845 + "engines": { 846 + "node": ">=4" 847 + } 848 + }, 205 849 "node_modules/cssstyle": { 206 850 "version": "4.6.0", 207 851 "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", ··· 266 910 "node": ">=0.4.0" 267 911 } 268 912 }, 913 + "node_modules/detect-libc": { 914 + "version": "2.1.2", 915 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 916 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 917 + "license": "Apache-2.0", 918 + "engines": { 919 + "node": ">=8" 920 + } 921 + }, 269 922 "node_modules/dunder-proto": { 270 923 "version": "1.0.1", 271 924 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", ··· 280 933 "node": ">= 0.4" 281 934 } 282 935 }, 936 + "node_modules/enhanced-resolve": { 937 + "version": "5.19.0", 938 + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", 939 + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", 940 + "license": "MIT", 941 + "dependencies": { 942 + "graceful-fs": "^4.2.4", 943 + "tapable": "^2.3.0" 944 + }, 945 + "engines": { 946 + "node": ">=10.13.0" 947 + } 948 + }, 283 949 "node_modules/entities": { 284 950 "version": "6.0.1", 285 951 "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", ··· 471 1137 "url": "https://github.com/sponsors/ljharb" 472 1138 } 473 1139 }, 1140 + "node_modules/graceful-fs": { 1141 + "version": "4.2.11", 1142 + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", 1143 + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", 1144 + "license": "ISC" 1145 + }, 474 1146 "node_modules/gray-matter": { 475 1147 "version": "4.0.3", 476 1148 "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", ··· 594 1266 "node": ">=0.10.0" 595 1267 } 596 1268 }, 1269 + "node_modules/is-extglob": { 1270 + "version": "2.1.1", 1271 + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 1272 + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 1273 + "license": "MIT", 1274 + "engines": { 1275 + "node": ">=0.10.0" 1276 + } 1277 + }, 1278 + "node_modules/is-glob": { 1279 + "version": "4.0.3", 1280 + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 1281 + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 1282 + "license": "MIT", 1283 + "dependencies": { 1284 + "is-extglob": "^2.1.1" 1285 + }, 1286 + "engines": { 1287 + "node": ">=0.10.0" 1288 + } 1289 + }, 597 1290 "node_modules/is-potential-custom-element-name": { 598 1291 "version": "1.0.1", 599 1292 "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", ··· 615 1308 "version": "2.0.0", 616 1309 "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 617 1310 "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" 1311 + }, 1312 + "node_modules/jiti": { 1313 + "version": "2.6.1", 1314 + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", 1315 + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", 1316 + "license": "MIT", 1317 + "bin": { 1318 + "jiti": "lib/jiti-cli.mjs" 1319 + } 618 1320 }, 619 1321 "node_modules/js-yaml": { 620 1322 "version": "3.14.2", ··· 680 1382 "node": ">=0.10.0" 681 1383 } 682 1384 }, 1385 + "node_modules/lightningcss": { 1386 + "version": "1.30.2", 1387 + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", 1388 + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", 1389 + "license": "MPL-2.0", 1390 + "dependencies": { 1391 + "detect-libc": "^2.0.3" 1392 + }, 1393 + "engines": { 1394 + "node": ">= 12.0.0" 1395 + }, 1396 + "funding": { 1397 + "type": "opencollective", 1398 + "url": "https://opencollective.com/parcel" 1399 + }, 1400 + "optionalDependencies": { 1401 + "lightningcss-android-arm64": "1.30.2", 1402 + "lightningcss-darwin-arm64": "1.30.2", 1403 + "lightningcss-darwin-x64": "1.30.2", 1404 + "lightningcss-freebsd-x64": "1.30.2", 1405 + "lightningcss-linux-arm-gnueabihf": "1.30.2", 1406 + "lightningcss-linux-arm64-gnu": "1.30.2", 1407 + "lightningcss-linux-arm64-musl": "1.30.2", 1408 + "lightningcss-linux-x64-gnu": "1.30.2", 1409 + "lightningcss-linux-x64-musl": "1.30.2", 1410 + "lightningcss-win32-arm64-msvc": "1.30.2", 1411 + "lightningcss-win32-x64-msvc": "1.30.2" 1412 + } 1413 + }, 1414 + "node_modules/lightningcss-android-arm64": { 1415 + "version": "1.30.2", 1416 + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", 1417 + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", 1418 + "cpu": [ 1419 + "arm64" 1420 + ], 1421 + "license": "MPL-2.0", 1422 + "optional": true, 1423 + "os": [ 1424 + "android" 1425 + ], 1426 + "engines": { 1427 + "node": ">= 12.0.0" 1428 + }, 1429 + "funding": { 1430 + "type": "opencollective", 1431 + "url": "https://opencollective.com/parcel" 1432 + } 1433 + }, 1434 + "node_modules/lightningcss-darwin-arm64": { 1435 + "version": "1.30.2", 1436 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", 1437 + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", 1438 + "cpu": [ 1439 + "arm64" 1440 + ], 1441 + "license": "MPL-2.0", 1442 + "optional": true, 1443 + "os": [ 1444 + "darwin" 1445 + ], 1446 + "engines": { 1447 + "node": ">= 12.0.0" 1448 + }, 1449 + "funding": { 1450 + "type": "opencollective", 1451 + "url": "https://opencollective.com/parcel" 1452 + } 1453 + }, 1454 + "node_modules/lightningcss-darwin-x64": { 1455 + "version": "1.30.2", 1456 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", 1457 + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", 1458 + "cpu": [ 1459 + "x64" 1460 + ], 1461 + "license": "MPL-2.0", 1462 + "optional": true, 1463 + "os": [ 1464 + "darwin" 1465 + ], 1466 + "engines": { 1467 + "node": ">= 12.0.0" 1468 + }, 1469 + "funding": { 1470 + "type": "opencollective", 1471 + "url": "https://opencollective.com/parcel" 1472 + } 1473 + }, 1474 + "node_modules/lightningcss-freebsd-x64": { 1475 + "version": "1.30.2", 1476 + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", 1477 + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", 1478 + "cpu": [ 1479 + "x64" 1480 + ], 1481 + "license": "MPL-2.0", 1482 + "optional": true, 1483 + "os": [ 1484 + "freebsd" 1485 + ], 1486 + "engines": { 1487 + "node": ">= 12.0.0" 1488 + }, 1489 + "funding": { 1490 + "type": "opencollective", 1491 + "url": "https://opencollective.com/parcel" 1492 + } 1493 + }, 1494 + "node_modules/lightningcss-linux-arm-gnueabihf": { 1495 + "version": "1.30.2", 1496 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", 1497 + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", 1498 + "cpu": [ 1499 + "arm" 1500 + ], 1501 + "license": "MPL-2.0", 1502 + "optional": true, 1503 + "os": [ 1504 + "linux" 1505 + ], 1506 + "engines": { 1507 + "node": ">= 12.0.0" 1508 + }, 1509 + "funding": { 1510 + "type": "opencollective", 1511 + "url": "https://opencollective.com/parcel" 1512 + } 1513 + }, 1514 + "node_modules/lightningcss-linux-arm64-gnu": { 1515 + "version": "1.30.2", 1516 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", 1517 + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", 1518 + "cpu": [ 1519 + "arm64" 1520 + ], 1521 + "license": "MPL-2.0", 1522 + "optional": true, 1523 + "os": [ 1524 + "linux" 1525 + ], 1526 + "engines": { 1527 + "node": ">= 12.0.0" 1528 + }, 1529 + "funding": { 1530 + "type": "opencollective", 1531 + "url": "https://opencollective.com/parcel" 1532 + } 1533 + }, 1534 + "node_modules/lightningcss-linux-arm64-musl": { 1535 + "version": "1.30.2", 1536 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", 1537 + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", 1538 + "cpu": [ 1539 + "arm64" 1540 + ], 1541 + "license": "MPL-2.0", 1542 + "optional": true, 1543 + "os": [ 1544 + "linux" 1545 + ], 1546 + "engines": { 1547 + "node": ">= 12.0.0" 1548 + }, 1549 + "funding": { 1550 + "type": "opencollective", 1551 + "url": "https://opencollective.com/parcel" 1552 + } 1553 + }, 1554 + "node_modules/lightningcss-linux-x64-gnu": { 1555 + "version": "1.30.2", 1556 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", 1557 + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", 1558 + "cpu": [ 1559 + "x64" 1560 + ], 1561 + "license": "MPL-2.0", 1562 + "optional": true, 1563 + "os": [ 1564 + "linux" 1565 + ], 1566 + "engines": { 1567 + "node": ">= 12.0.0" 1568 + }, 1569 + "funding": { 1570 + "type": "opencollective", 1571 + "url": "https://opencollective.com/parcel" 1572 + } 1573 + }, 1574 + "node_modules/lightningcss-linux-x64-musl": { 1575 + "version": "1.30.2", 1576 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", 1577 + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", 1578 + "cpu": [ 1579 + "x64" 1580 + ], 1581 + "license": "MPL-2.0", 1582 + "optional": true, 1583 + "os": [ 1584 + "linux" 1585 + ], 1586 + "engines": { 1587 + "node": ">= 12.0.0" 1588 + }, 1589 + "funding": { 1590 + "type": "opencollective", 1591 + "url": "https://opencollective.com/parcel" 1592 + } 1593 + }, 1594 + "node_modules/lightningcss-win32-arm64-msvc": { 1595 + "version": "1.30.2", 1596 + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", 1597 + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", 1598 + "cpu": [ 1599 + "arm64" 1600 + ], 1601 + "license": "MPL-2.0", 1602 + "optional": true, 1603 + "os": [ 1604 + "win32" 1605 + ], 1606 + "engines": { 1607 + "node": ">= 12.0.0" 1608 + }, 1609 + "funding": { 1610 + "type": "opencollective", 1611 + "url": "https://opencollective.com/parcel" 1612 + } 1613 + }, 1614 + "node_modules/lightningcss-win32-x64-msvc": { 1615 + "version": "1.30.2", 1616 + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", 1617 + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", 1618 + "cpu": [ 1619 + "x64" 1620 + ], 1621 + "license": "MPL-2.0", 1622 + "optional": true, 1623 + "os": [ 1624 + "win32" 1625 + ], 1626 + "engines": { 1627 + "node": ">= 12.0.0" 1628 + }, 1629 + "funding": { 1630 + "type": "opencollective", 1631 + "url": "https://opencollective.com/parcel" 1632 + } 1633 + }, 683 1634 "node_modules/lru-cache": { 684 1635 "version": "10.4.3", 685 1636 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 686 1637 "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 687 1638 "license": "ISC" 688 1639 }, 1640 + "node_modules/magic-string": { 1641 + "version": "0.30.21", 1642 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", 1643 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", 1644 + "license": "MIT", 1645 + "dependencies": { 1646 + "@jridgewell/sourcemap-codec": "^1.5.5" 1647 + } 1648 + }, 689 1649 "node_modules/math-intrinsics": { 690 1650 "version": "1.1.0", 691 1651 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", ··· 732 1692 "url": "https://github.com/sponsors/sindresorhus" 733 1693 } 734 1694 }, 1695 + "node_modules/mini-svg-data-uri": { 1696 + "version": "1.4.4", 1697 + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", 1698 + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", 1699 + "dev": true, 1700 + "license": "MIT", 1701 + "bin": { 1702 + "mini-svg-data-uri": "cli.js" 1703 + } 1704 + }, 1705 + "node_modules/mri": { 1706 + "version": "1.2.0", 1707 + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", 1708 + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", 1709 + "license": "MIT", 1710 + "engines": { 1711 + "node": ">=4" 1712 + } 1713 + }, 735 1714 "node_modules/ms": { 736 1715 "version": "2.1.3", 737 1716 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 738 1717 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1718 + "license": "MIT" 1719 + }, 1720 + "node_modules/node-addon-api": { 1721 + "version": "7.1.1", 1722 + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", 1723 + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", 739 1724 "license": "MIT" 740 1725 }, 741 1726 "node_modules/npm-run-path": { ··· 803 1788 "node": ">=8" 804 1789 } 805 1790 }, 1791 + "node_modules/picocolors": { 1792 + "version": "1.1.1", 1793 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1794 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1795 + "license": "ISC" 1796 + }, 1797 + "node_modules/picomatch": { 1798 + "version": "4.0.3", 1799 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 1800 + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 1801 + "license": "MIT", 1802 + "engines": { 1803 + "node": ">=12" 1804 + }, 1805 + "funding": { 1806 + "url": "https://github.com/sponsors/jonschlinkert" 1807 + } 1808 + }, 1809 + "node_modules/postcss-selector-parser": { 1810 + "version": "6.0.10", 1811 + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", 1812 + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", 1813 + "dev": true, 1814 + "license": "MIT", 1815 + "dependencies": { 1816 + "cssesc": "^3.0.0", 1817 + "util-deprecate": "^1.0.2" 1818 + }, 1819 + "engines": { 1820 + "node": ">=4" 1821 + } 1822 + }, 806 1823 "node_modules/punycode": { 807 1824 "version": "2.3.1", 808 1825 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", ··· 880 1897 "url": "https://github.com/sponsors/isaacs" 881 1898 } 882 1899 }, 1900 + "node_modules/source-map-js": { 1901 + "version": "1.2.1", 1902 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 1903 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 1904 + "license": "BSD-3-Clause", 1905 + "engines": { 1906 + "node": ">=0.10.0" 1907 + } 1908 + }, 883 1909 "node_modules/sprintf-js": { 884 1910 "version": "1.0.3", 885 1911 "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", ··· 914 1940 "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", 915 1941 "license": "MIT" 916 1942 }, 1943 + "node_modules/tailwindcss": { 1944 + "version": "4.1.18", 1945 + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", 1946 + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", 1947 + "license": "MIT" 1948 + }, 1949 + "node_modules/tapable": { 1950 + "version": "2.3.0", 1951 + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", 1952 + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", 1953 + "license": "MIT", 1954 + "engines": { 1955 + "node": ">=6" 1956 + }, 1957 + "funding": { 1958 + "type": "opencollective", 1959 + "url": "https://opencollective.com/webpack" 1960 + } 1961 + }, 917 1962 "node_modules/tldts": { 918 1963 "version": "6.1.86", 919 1964 "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", ··· 955 2000 "engines": { 956 2001 "node": ">=18" 957 2002 } 2003 + }, 2004 + "node_modules/util-deprecate": { 2005 + "version": "1.0.2", 2006 + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 2007 + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 2008 + "dev": true, 2009 + "license": "MIT" 958 2010 }, 959 2011 "node_modules/w3c-xmlserializer": { 960 2012 "version": "5.0.0",
+7 -2
package.json
··· 10 10 "scripts": { 11 11 "test": "go test ./...", 12 12 "dev": "go run ./cmd/xesite --site-url https://preview.xeiaso.net --devel", 13 + "dev:sponsor-panel": "cd ./cmd/sponsor-panel && go generate ./... && go run .", 13 14 "deploy": "kubectl --context limsa-lominsa apply -k manifest", 14 15 "extract-meta": "node scripts/extract-meta.js", 15 16 "validate-content-dates": "node scripts/validate-blog-dates.js" ··· 25 26 }, 26 27 "homepage": "https://github.com/Xe/site#readme", 27 28 "dependencies": { 29 + "@tailwindcss/cli": "^4.1.18", 28 30 "execa": "^8.0.1", 29 31 "jsdom": "^25.0.0" 30 32 }, 31 33 "devDependencies": { 32 - "gray-matter": "^4.0.3" 34 + "@tailwindcss/forms": "^0.5.7", 35 + "@tailwindcss/typography": "^0.5.14", 36 + "gray-matter": "^4.0.3", 37 + "tailwindcss": "^4.1.18" 33 38 } 34 - } 39 + }
+5 -6
pb/external/mi/mi.pb.go
··· 2 2 3 3 // Code generated by protoc-gen-go. DO NOT EDIT. 4 4 // versions: 5 - // protoc-gen-go v1.36.10 6 - // protoc v6.33.0 5 + // protoc-gen-go v1.36.11 6 + // protoc v6.33.4 7 7 // source: mi.proto 8 8 9 9 package mi 10 10 11 11 import ( 12 - reflect "reflect" 13 - sync "sync" 14 - unsafe "unsafe" 15 - 16 12 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 17 13 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 18 14 emptypb "google.golang.org/protobuf/types/known/emptypb" 19 15 timestamppb "google.golang.org/protobuf/types/known/timestamppb" 16 + reflect "reflect" 17 + sync "sync" 18 + unsafe "unsafe" 20 19 ) 21 20 22 21 const (
+16 -29
pb/external/mi/mi.twirp.go
··· 3 3 4 4 package mi 5 5 6 - import ( 7 - context "context" 8 - fmt "fmt" 6 + import context "context" 7 + import fmt "fmt" 8 + import http "net/http" 9 + import io "io" 10 + import json "encoding/json" 11 + import strconv "strconv" 12 + import strings "strings" 9 13 10 - http "net/http" 14 + import protojson "google.golang.org/protobuf/encoding/protojson" 15 + import proto "google.golang.org/protobuf/proto" 16 + import twirp "github.com/twitchtv/twirp" 17 + import ctxsetters "github.com/twitchtv/twirp/ctxsetters" 11 18 12 - io "io" 19 + import google_protobuf "google.golang.org/protobuf/types/known/emptypb" 13 20 14 - json "encoding/json" 15 - 16 - strconv "strconv" 17 - 18 - strings "strings" 19 - 20 - protojson "google.golang.org/protobuf/encoding/protojson" 21 - 22 - proto "google.golang.org/protobuf/proto" 23 - 24 - twirp "github.com/twitchtv/twirp" 25 - 26 - ctxsetters "github.com/twitchtv/twirp/ctxsetters" 27 - 28 - google_protobuf "google.golang.org/protobuf/types/known/emptypb" 29 - 30 - bytes "bytes" 31 - 32 - errors "errors" 33 - 34 - path "path" 35 - 36 - url "net/url" 37 - ) 21 + import bytes "bytes" 22 + import errors "errors" 23 + import path "path" 24 + import url "net/url" 38 25 39 26 // Version compatibility assertion. 40 27 // If the constant is not defined in the package, that likely means
+4 -5
pb/external/mimi/announce/mimi-announce.pb.go
··· 1 1 // Code generated by protoc-gen-go. DO NOT EDIT. 2 2 // versions: 3 - // protoc-gen-go v1.36.10 4 - // protoc v6.33.0 3 + // protoc-gen-go v1.36.11 4 + // protoc v6.33.4 5 5 // source: mimi-announce.proto 6 6 7 7 package announce 8 8 9 9 import ( 10 - reflect "reflect" 11 - unsafe "unsafe" 12 - 13 10 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 14 11 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 15 12 emptypb "google.golang.org/protobuf/types/known/emptypb" 13 + reflect "reflect" 14 + unsafe "unsafe" 16 15 protofeed "xeiaso.net/v4/pb/external/protofeed" 17 16 ) 18 17
+17 -31
pb/external/mimi/announce/mimi-announce.twirp.go
··· 3 3 4 4 package announce 5 5 6 - import ( 7 - context "context" 8 - fmt "fmt" 9 - 10 - http "net/http" 11 - 12 - io "io" 13 - 14 - json "encoding/json" 15 - 16 - strconv "strconv" 17 - 18 - strings "strings" 19 - 20 - protojson "google.golang.org/protobuf/encoding/protojson" 21 - 22 - proto "google.golang.org/protobuf/proto" 23 - 24 - twirp "github.com/twitchtv/twirp" 25 - 26 - ctxsetters "github.com/twitchtv/twirp/ctxsetters" 27 - 28 - google_protobuf "google.golang.org/protobuf/types/known/emptypb" 29 - 30 - protofeed "xeiaso.net/v4/pb/external/protofeed" 31 - 32 - bytes "bytes" 6 + import context "context" 7 + import fmt "fmt" 8 + import http "net/http" 9 + import io "io" 10 + import json "encoding/json" 11 + import strconv "strconv" 12 + import strings "strings" 33 13 34 - errors "errors" 14 + import protojson "google.golang.org/protobuf/encoding/protojson" 15 + import proto "google.golang.org/protobuf/proto" 16 + import twirp "github.com/twitchtv/twirp" 17 + import ctxsetters "github.com/twitchtv/twirp/ctxsetters" 35 18 36 - path "path" 19 + import google_protobuf "google.golang.org/protobuf/types/known/emptypb" 20 + import protofeed "xeiaso.net/v4/pb/external/protofeed" 37 21 38 - url "net/url" 39 - ) 22 + import bytes "bytes" 23 + import errors "errors" 24 + import path "path" 25 + import url "net/url" 40 26 41 27 // Version compatibility assertion. 42 28 // If the constant is not defined in the package, that likely means
+5 -6
pb/external/protofeed/protofeed.pb.go
··· 1 1 // Code generated by protoc-gen-go. DO NOT EDIT. 2 2 // versions: 3 - // protoc-gen-go v1.36.10 4 - // protoc v6.33.0 3 + // protoc-gen-go v1.36.11 4 + // protoc v6.33.4 5 5 // source: protofeed.proto 6 6 7 7 package protofeed 8 8 9 9 import ( 10 + protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 + protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 + timestamppb "google.golang.org/protobuf/types/known/timestamppb" 10 13 reflect "reflect" 11 14 sync "sync" 12 15 unsafe "unsafe" 13 - 14 - protoreflect "google.golang.org/protobuf/reflect/protoreflect" 15 - protoimpl "google.golang.org/protobuf/runtime/protoimpl" 16 - timestamppb "google.golang.org/protobuf/types/known/timestamppb" 17 16 ) 18 17 19 18 const (
+5 -6
pb/xesite.pb.go
··· 1 1 // Code generated by protoc-gen-go. DO NOT EDIT. 2 2 // versions: 3 - // protoc-gen-go v1.36.10 4 - // protoc v6.33.0 3 + // protoc-gen-go v1.36.11 4 + // protoc v6.33.4 5 5 // source: xesite.proto 6 6 7 7 package pb 8 8 9 9 import ( 10 - reflect "reflect" 11 - sync "sync" 12 - unsafe "unsafe" 13 - 14 10 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 15 11 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 16 12 emptypb "google.golang.org/protobuf/types/known/emptypb" 17 13 timestamppb "google.golang.org/protobuf/types/known/timestamppb" 14 + reflect "reflect" 15 + sync "sync" 16 + unsafe "unsafe" 18 17 _ "xeiaso.net/v4/pb/external/mi" 19 18 protofeed "xeiaso.net/v4/pb/external/protofeed" 20 19 )
+17 -31
pb/xesite.twirp.go
··· 3 3 4 4 package pb 5 5 6 - import ( 7 - context "context" 8 - fmt "fmt" 9 - 10 - http "net/http" 11 - 12 - io "io" 13 - 14 - json "encoding/json" 15 - 16 - strconv "strconv" 17 - 18 - strings "strings" 19 - 20 - protojson "google.golang.org/protobuf/encoding/protojson" 21 - 22 - proto "google.golang.org/protobuf/proto" 23 - 24 - twirp "github.com/twitchtv/twirp" 25 - 26 - ctxsetters "github.com/twitchtv/twirp/ctxsetters" 27 - 28 - google_protobuf "google.golang.org/protobuf/types/known/emptypb" 29 - 30 - protofeed "xeiaso.net/v4/pb/external/protofeed" 31 - 32 - bytes "bytes" 6 + import context "context" 7 + import fmt "fmt" 8 + import http "net/http" 9 + import io "io" 10 + import json "encoding/json" 11 + import strconv "strconv" 12 + import strings "strings" 33 13 34 - errors "errors" 14 + import protojson "google.golang.org/protobuf/encoding/protojson" 15 + import proto "google.golang.org/protobuf/proto" 16 + import twirp "github.com/twitchtv/twirp" 17 + import ctxsetters "github.com/twitchtv/twirp/ctxsetters" 35 18 36 - path "path" 19 + import google_protobuf "google.golang.org/protobuf/types/known/emptypb" 20 + import protofeed "xeiaso.net/v4/pb/external/protofeed" 37 21 38 - url "net/url" 39 - ) 22 + import bytes "bytes" 23 + import errors "errors" 24 + import path "path" 25 + import url "net/url" 40 26 41 27 // Version compatibility assertion. 42 28 // If the constant is not defined in the package, that likely means
+39
web/htmx/event-header.js
··· 1 + (function () { 2 + function stringifyEvent(event) { 3 + var obj = {}; 4 + for (var key in event) { 5 + obj[key] = event[key]; 6 + } 7 + return JSON.stringify(obj, function (key, value) { 8 + if (value instanceof Node) { 9 + var nodeRep = value.tagName; 10 + if (nodeRep) { 11 + nodeRep = nodeRep.toLowerCase(); 12 + if (value.id) { 13 + nodeRep += "#" + value.id; 14 + } 15 + if (value.classList && value.classList.length) { 16 + nodeRep += "." + value.classList.toString().replace(" ", "."); 17 + } 18 + return nodeRep; 19 + } else { 20 + return "Node"; 21 + } 22 + } 23 + if (value instanceof Window) return "Window"; 24 + return value; 25 + }); 26 + } 27 + 28 + htmx.defineExtension("event-header", { 29 + onEvent: function (name, evt) { 30 + if (name === "htmx:configRequest") { 31 + if (evt.detail.triggeringEvent) { 32 + evt.detail.headers["Triggering-Event"] = stringifyEvent( 33 + evt.detail.triggeringEvent, 34 + ); 35 + } 36 + } 37 + }, 38 + }); 39 + })();
+12
web/htmx/headers.go
··· 1 + package htmx 2 + 3 + import ( 4 + "net/http" 5 + ) 6 + 7 + func UnchangingCache(h http.Handler) http.Handler { 8 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 9 + w.Header().Set("Cache-Control", "public, max-age=31536000") 10 + h.ServeHTTP(w, r) 11 + }) 12 + }
+51
web/htmx/htmx.go
··· 1 + package htmx 2 + 3 + import ( 4 + "embed" 5 + "net/http" 6 + ) 7 + 8 + //go:generate go tool templ generate 9 + 10 + var ( 11 + //go:embed *.js 12 + Static embed.FS 13 + ) 14 + 15 + func init() { 16 + Mount(http.DefaultServeMux) 17 + } 18 + 19 + // URL is the folder path where the HTMX static files are served from. 20 + const URL = "/.within.website/x/htmx/" 21 + 22 + // Mount the HTMX static directory to a given path on a ServeMux. 23 + // 24 + // If you use the Core or Use functions, you will need to ensure that HTMX is mounted at the correct path. Otherwise it will not be able to find the required JavaScript files. 25 + func Mount(mux *http.ServeMux) { 26 + hdlr := http.StripPrefix(URL, http.FileServer(http.FS(Static))) 27 + hdlr = UnchangingCache(hdlr) 28 + 29 + mux.Handle(URL, hdlr) 30 + } 31 + 32 + // HTTP request headers 33 + const ( 34 + // Request header for the user response to an hx-prompt. 35 + HeaderPrompt = "HX-Prompt" 36 + 37 + // Request header that is always “true” for HTMX requests. 38 + HeaderRequest = "Hx-Request" 39 + ) 40 + 41 + // 286 Stop Polling 42 + // 43 + // HTTP status code that tells HTMX to stop polling from a server response. 44 + // 45 + // For more info, see https://htmx.org/docs/#load_polling 46 + const StatusStopPolling int = 286 47 + 48 + // Is returns true if the given request was made by HTMX. 49 + func Is(r *http.Request) bool { 50 + return r.Header.Get(HeaderRequest) == "true" 51 + }
+3429
web/htmx/htmx.js
··· 1 + var htmx = (function () { 2 + "use strict"; 3 + const Q = { 4 + onLoad: null, 5 + process: null, 6 + on: null, 7 + off: null, 8 + trigger: null, 9 + ajax: null, 10 + find: null, 11 + findAll: null, 12 + closest: null, 13 + values: function (e, t) { 14 + const n = cn(e, t || "post"); 15 + return n.values; 16 + }, 17 + remove: null, 18 + addClass: null, 19 + removeClass: null, 20 + toggleClass: null, 21 + takeClass: null, 22 + swap: null, 23 + defineExtension: null, 24 + removeExtension: null, 25 + logAll: null, 26 + logNone: null, 27 + logger: null, 28 + config: { 29 + historyEnabled: true, 30 + historyCacheSize: 10, 31 + refreshOnHistoryMiss: false, 32 + defaultSwapStyle: "innerHTML", 33 + defaultSwapDelay: 0, 34 + defaultSettleDelay: 20, 35 + includeIndicatorStyles: true, 36 + indicatorClass: "htmx-indicator", 37 + requestClass: "htmx-request", 38 + addedClass: "htmx-added", 39 + settlingClass: "htmx-settling", 40 + swappingClass: "htmx-swapping", 41 + allowEval: true, 42 + allowScriptTags: true, 43 + inlineScriptNonce: "", 44 + inlineStyleNonce: "", 45 + attributesToSettle: ["class", "style", "width", "height"], 46 + withCredentials: false, 47 + timeout: 0, 48 + wsReconnectDelay: "full-jitter", 49 + wsBinaryType: "blob", 50 + disableSelector: "[hx-disable], [data-hx-disable]", 51 + scrollBehavior: "instant", 52 + defaultFocusScroll: false, 53 + getCacheBusterParam: false, 54 + globalViewTransitions: false, 55 + methodsThatUseUrlParams: ["get", "delete"], 56 + selfRequestsOnly: true, 57 + ignoreTitle: false, 58 + scrollIntoViewOnBoost: true, 59 + triggerSpecsCache: null, 60 + disableInheritance: false, 61 + responseHandling: [ 62 + { code: "204", swap: false }, 63 + { code: "[23]..", swap: true }, 64 + { code: "[45]..", swap: false, error: true }, 65 + ], 66 + allowNestedOobSwaps: true, 67 + }, 68 + parseInterval: null, 69 + _: null, 70 + version: "2.0.2", 71 + }; 72 + Q.onLoad = $; 73 + Q.process = Dt; 74 + Q.on = be; 75 + Q.off = we; 76 + Q.trigger = de; 77 + Q.ajax = Hn; 78 + Q.find = r; 79 + Q.findAll = p; 80 + Q.closest = g; 81 + Q.remove = K; 82 + Q.addClass = Y; 83 + Q.removeClass = o; 84 + Q.toggleClass = W; 85 + Q.takeClass = ge; 86 + Q.swap = ze; 87 + Q.defineExtension = Bn; 88 + Q.removeExtension = Un; 89 + Q.logAll = z; 90 + Q.logNone = J; 91 + Q.parseInterval = h; 92 + Q._ = _; 93 + const n = { 94 + addTriggerHandler: Et, 95 + bodyContains: le, 96 + canAccessLocalStorage: j, 97 + findThisElement: Ee, 98 + filterValues: hn, 99 + swap: ze, 100 + hasAttribute: s, 101 + getAttributeValue: te, 102 + getClosestAttributeValue: re, 103 + getClosestMatch: T, 104 + getExpressionVars: Cn, 105 + getHeaders: dn, 106 + getInputValues: cn, 107 + getInternalData: ie, 108 + getSwapSpecification: pn, 109 + getTriggerSpecs: lt, 110 + getTarget: Ce, 111 + makeFragment: D, 112 + mergeObjects: ue, 113 + makeSettleInfo: xn, 114 + oobSwap: Te, 115 + querySelectorExt: ae, 116 + settleImmediately: Gt, 117 + shouldCancel: ht, 118 + triggerEvent: de, 119 + triggerErrorEvent: fe, 120 + withExtensions: Bt, 121 + }; 122 + const v = ["get", "post", "put", "delete", "patch"]; 123 + const O = v 124 + .map(function (e) { 125 + return "[hx-" + e + "], [data-hx-" + e + "]"; 126 + }) 127 + .join(", "); 128 + const R = e("head"); 129 + function e(e, t = false) { 130 + return new RegExp( 131 + `<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`, 132 + t ? "gim" : "im", 133 + ); 134 + } 135 + function h(e) { 136 + if (e == undefined) { 137 + return undefined; 138 + } 139 + let t = NaN; 140 + if (e.slice(-2) == "ms") { 141 + t = parseFloat(e.slice(0, -2)); 142 + } else if (e.slice(-1) == "s") { 143 + t = parseFloat(e.slice(0, -1)) * 1e3; 144 + } else if (e.slice(-1) == "m") { 145 + t = parseFloat(e.slice(0, -1)) * 1e3 * 60; 146 + } else { 147 + t = parseFloat(e); 148 + } 149 + return isNaN(t) ? undefined : t; 150 + } 151 + function ee(e, t) { 152 + return e instanceof Element && e.getAttribute(t); 153 + } 154 + function s(e, t) { 155 + return ( 156 + !!e.hasAttribute && (e.hasAttribute(t) || e.hasAttribute("data-" + t)) 157 + ); 158 + } 159 + function te(e, t) { 160 + return ee(e, t) || ee(e, "data-" + t); 161 + } 162 + function u(e) { 163 + const t = e.parentElement; 164 + if (!t && e.parentNode instanceof ShadowRoot) return e.parentNode; 165 + return t; 166 + } 167 + function ne() { 168 + return document; 169 + } 170 + function H(e, t) { 171 + return e.getRootNode ? e.getRootNode({ composed: t }) : ne(); 172 + } 173 + function T(e, t) { 174 + while (e && !t(e)) { 175 + e = u(e); 176 + } 177 + return e || null; 178 + } 179 + function q(e, t, n) { 180 + const r = te(t, n); 181 + const o = te(t, "hx-disinherit"); 182 + var i = te(t, "hx-inherit"); 183 + if (e !== t) { 184 + if (Q.config.disableInheritance) { 185 + if (i && (i === "*" || i.split(" ").indexOf(n) >= 0)) { 186 + return r; 187 + } else { 188 + return null; 189 + } 190 + } 191 + if (o && (o === "*" || o.split(" ").indexOf(n) >= 0)) { 192 + return "unset"; 193 + } 194 + } 195 + return r; 196 + } 197 + function re(t, n) { 198 + let r = null; 199 + T(t, function (e) { 200 + return !!(r = q(t, ce(e), n)); 201 + }); 202 + if (r !== "unset") { 203 + return r; 204 + } 205 + } 206 + function f(e, t) { 207 + const n = 208 + e instanceof Element && 209 + (e.matches || 210 + e.matchesSelector || 211 + e.msMatchesSelector || 212 + e.mozMatchesSelector || 213 + e.webkitMatchesSelector || 214 + e.oMatchesSelector); 215 + return !!n && n.call(e, t); 216 + } 217 + function L(e) { 218 + const t = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i; 219 + const n = t.exec(e); 220 + if (n) { 221 + return n[1].toLowerCase(); 222 + } else { 223 + return ""; 224 + } 225 + } 226 + function N(e) { 227 + const t = new DOMParser(); 228 + return t.parseFromString(e, "text/html"); 229 + } 230 + function A(e, t) { 231 + while (t.childNodes.length > 0) { 232 + e.append(t.childNodes[0]); 233 + } 234 + } 235 + function I(e) { 236 + const t = ne().createElement("script"); 237 + se(e.attributes, function (e) { 238 + t.setAttribute(e.name, e.value); 239 + }); 240 + t.textContent = e.textContent; 241 + t.async = false; 242 + if (Q.config.inlineScriptNonce) { 243 + t.nonce = Q.config.inlineScriptNonce; 244 + } 245 + return t; 246 + } 247 + function P(e) { 248 + return ( 249 + e.matches("script") && 250 + (e.type === "text/javascript" || e.type === "module" || e.type === "") 251 + ); 252 + } 253 + function k(e) { 254 + Array.from(e.querySelectorAll("script")).forEach((e) => { 255 + if (P(e)) { 256 + const t = I(e); 257 + const n = e.parentNode; 258 + try { 259 + n.insertBefore(t, e); 260 + } catch (e) { 261 + w(e); 262 + } finally { 263 + e.remove(); 264 + } 265 + } 266 + }); 267 + } 268 + function D(e) { 269 + const t = e.replace(R, ""); 270 + const n = L(t); 271 + let r; 272 + if (n === "html") { 273 + r = new DocumentFragment(); 274 + const i = N(e); 275 + A(r, i.body); 276 + r.title = i.title; 277 + } else if (n === "body") { 278 + r = new DocumentFragment(); 279 + const i = N(t); 280 + A(r, i.body); 281 + r.title = i.title; 282 + } else { 283 + const i = N( 284 + '<body><template class="internal-htmx-wrapper">' + 285 + t + 286 + "</template></body>", 287 + ); 288 + r = i.querySelector("template").content; 289 + r.title = i.title; 290 + var o = r.querySelector("title"); 291 + if (o && o.parentNode === r) { 292 + o.remove(); 293 + r.title = o.innerText; 294 + } 295 + } 296 + if (r) { 297 + if (Q.config.allowScriptTags) { 298 + k(r); 299 + } else { 300 + r.querySelectorAll("script").forEach((e) => e.remove()); 301 + } 302 + } 303 + return r; 304 + } 305 + function oe(e) { 306 + if (e) { 307 + e(); 308 + } 309 + } 310 + function t(e, t) { 311 + return Object.prototype.toString.call(e) === "[object " + t + "]"; 312 + } 313 + function M(e) { 314 + return typeof e === "function"; 315 + } 316 + function X(e) { 317 + return t(e, "Object"); 318 + } 319 + function ie(e) { 320 + const t = "htmx-internal-data"; 321 + let n = e[t]; 322 + if (!n) { 323 + n = e[t] = {}; 324 + } 325 + return n; 326 + } 327 + function F(t) { 328 + const n = []; 329 + if (t) { 330 + for (let e = 0; e < t.length; e++) { 331 + n.push(t[e]); 332 + } 333 + } 334 + return n; 335 + } 336 + function se(t, n) { 337 + if (t) { 338 + for (let e = 0; e < t.length; e++) { 339 + n(t[e]); 340 + } 341 + } 342 + } 343 + function B(e) { 344 + const t = e.getBoundingClientRect(); 345 + const n = t.top; 346 + const r = t.bottom; 347 + return n < window.innerHeight && r >= 0; 348 + } 349 + function le(e) { 350 + const t = e.getRootNode && e.getRootNode(); 351 + if (t && t instanceof window.ShadowRoot) { 352 + return ne().body.contains(t.host); 353 + } else { 354 + return ne().body.contains(e); 355 + } 356 + } 357 + function U(e) { 358 + return e.trim().split(/\s+/); 359 + } 360 + function ue(e, t) { 361 + for (const n in t) { 362 + if (t.hasOwnProperty(n)) { 363 + e[n] = t[n]; 364 + } 365 + } 366 + return e; 367 + } 368 + function S(e) { 369 + try { 370 + return JSON.parse(e); 371 + } catch (e) { 372 + w(e); 373 + return null; 374 + } 375 + } 376 + function j() { 377 + const e = "htmx:localStorageTest"; 378 + try { 379 + localStorage.setItem(e, e); 380 + localStorage.removeItem(e); 381 + return true; 382 + } catch (e) { 383 + return false; 384 + } 385 + } 386 + function V(t) { 387 + try { 388 + const e = new URL(t); 389 + if (e) { 390 + t = e.pathname + e.search; 391 + } 392 + if (!/^\/$/.test(t)) { 393 + t = t.replace(/\/+$/, ""); 394 + } 395 + return t; 396 + } catch (e) { 397 + return t; 398 + } 399 + } 400 + function _(e) { 401 + return vn(ne().body, function () { 402 + return eval(e); 403 + }); 404 + } 405 + function $(t) { 406 + const e = Q.on("htmx:load", function (e) { 407 + t(e.detail.elt); 408 + }); 409 + return e; 410 + } 411 + function z() { 412 + Q.logger = function (e, t, n) { 413 + if (console) { 414 + console.log(t, e, n); 415 + } 416 + }; 417 + } 418 + function J() { 419 + Q.logger = null; 420 + } 421 + function r(e, t) { 422 + if (typeof e !== "string") { 423 + return e.querySelector(t); 424 + } else { 425 + return r(ne(), e); 426 + } 427 + } 428 + function p(e, t) { 429 + if (typeof e !== "string") { 430 + return e.querySelectorAll(t); 431 + } else { 432 + return p(ne(), e); 433 + } 434 + } 435 + function E() { 436 + return window; 437 + } 438 + function K(e, t) { 439 + e = y(e); 440 + if (t) { 441 + E().setTimeout(function () { 442 + K(e); 443 + e = null; 444 + }, t); 445 + } else { 446 + u(e).removeChild(e); 447 + } 448 + } 449 + function ce(e) { 450 + return e instanceof Element ? e : null; 451 + } 452 + function G(e) { 453 + return e instanceof HTMLElement ? e : null; 454 + } 455 + function Z(e) { 456 + return typeof e === "string" ? e : null; 457 + } 458 + function d(e) { 459 + return e instanceof Element || 460 + e instanceof Document || 461 + e instanceof DocumentFragment 462 + ? e 463 + : null; 464 + } 465 + function Y(e, t, n) { 466 + e = ce(y(e)); 467 + if (!e) { 468 + return; 469 + } 470 + if (n) { 471 + E().setTimeout(function () { 472 + Y(e, t); 473 + e = null; 474 + }, n); 475 + } else { 476 + e.classList && e.classList.add(t); 477 + } 478 + } 479 + function o(e, t, n) { 480 + let r = ce(y(e)); 481 + if (!r) { 482 + return; 483 + } 484 + if (n) { 485 + E().setTimeout(function () { 486 + o(r, t); 487 + r = null; 488 + }, n); 489 + } else { 490 + if (r.classList) { 491 + r.classList.remove(t); 492 + if (r.classList.length === 0) { 493 + r.removeAttribute("class"); 494 + } 495 + } 496 + } 497 + } 498 + function W(e, t) { 499 + e = y(e); 500 + e.classList.toggle(t); 501 + } 502 + function ge(e, t) { 503 + e = y(e); 504 + se(e.parentElement.children, function (e) { 505 + o(e, t); 506 + }); 507 + Y(ce(e), t); 508 + } 509 + function g(e, t) { 510 + e = ce(y(e)); 511 + if (e && e.closest) { 512 + return e.closest(t); 513 + } else { 514 + do { 515 + if (e == null || f(e, t)) { 516 + return e; 517 + } 518 + } while ((e = e && ce(u(e)))); 519 + return null; 520 + } 521 + } 522 + function l(e, t) { 523 + return e.substring(0, t.length) === t; 524 + } 525 + function pe(e, t) { 526 + return e.substring(e.length - t.length) === t; 527 + } 528 + function i(e) { 529 + const t = e.trim(); 530 + if (l(t, "<") && pe(t, "/>")) { 531 + return t.substring(1, t.length - 2); 532 + } else { 533 + return t; 534 + } 535 + } 536 + function m(e, t, n) { 537 + e = y(e); 538 + if (t.indexOf("closest ") === 0) { 539 + return [g(ce(e), i(t.substr(8)))]; 540 + } else if (t.indexOf("find ") === 0) { 541 + return [r(d(e), i(t.substr(5)))]; 542 + } else if (t === "next") { 543 + return [ce(e).nextElementSibling]; 544 + } else if (t.indexOf("next ") === 0) { 545 + return [me(e, i(t.substr(5)), !!n)]; 546 + } else if (t === "previous") { 547 + return [ce(e).previousElementSibling]; 548 + } else if (t.indexOf("previous ") === 0) { 549 + return [ye(e, i(t.substr(9)), !!n)]; 550 + } else if (t === "document") { 551 + return [document]; 552 + } else if (t === "window") { 553 + return [window]; 554 + } else if (t === "body") { 555 + return [document.body]; 556 + } else if (t === "root") { 557 + return [H(e, !!n)]; 558 + } else if (t.indexOf("global ") === 0) { 559 + return m(e, t.slice(7), true); 560 + } else { 561 + return F(d(H(e, !!n)).querySelectorAll(i(t))); 562 + } 563 + } 564 + var me = function (t, e, n) { 565 + const r = d(H(t, n)).querySelectorAll(e); 566 + for (let e = 0; e < r.length; e++) { 567 + const o = r[e]; 568 + if (o.compareDocumentPosition(t) === Node.DOCUMENT_POSITION_PRECEDING) { 569 + return o; 570 + } 571 + } 572 + }; 573 + var ye = function (t, e, n) { 574 + const r = d(H(t, n)).querySelectorAll(e); 575 + for (let e = r.length - 1; e >= 0; e--) { 576 + const o = r[e]; 577 + if (o.compareDocumentPosition(t) === Node.DOCUMENT_POSITION_FOLLOWING) { 578 + return o; 579 + } 580 + } 581 + }; 582 + function ae(e, t) { 583 + if (typeof e !== "string") { 584 + return m(e, t)[0]; 585 + } else { 586 + return m(ne().body, e)[0]; 587 + } 588 + } 589 + function y(e, t) { 590 + if (typeof e === "string") { 591 + return r(d(t) || document, e); 592 + } else { 593 + return e; 594 + } 595 + } 596 + function xe(e, t, n) { 597 + if (M(t)) { 598 + return { target: ne().body, event: Z(e), listener: t }; 599 + } else { 600 + return { target: y(e), event: Z(t), listener: n }; 601 + } 602 + } 603 + function be(t, n, r) { 604 + _n(function () { 605 + const e = xe(t, n, r); 606 + e.target.addEventListener(e.event, e.listener); 607 + }); 608 + const e = M(n); 609 + return e ? n : r; 610 + } 611 + function we(t, n, r) { 612 + _n(function () { 613 + const e = xe(t, n, r); 614 + e.target.removeEventListener(e.event, e.listener); 615 + }); 616 + return M(n) ? n : r; 617 + } 618 + const ve = ne().createElement("output"); 619 + function Se(e, t) { 620 + const n = re(e, t); 621 + if (n) { 622 + if (n === "this") { 623 + return [Ee(e, t)]; 624 + } else { 625 + const r = m(e, n); 626 + if (r.length === 0) { 627 + w('The selector "' + n + '" on ' + t + " returned no matches!"); 628 + return [ve]; 629 + } else { 630 + return r; 631 + } 632 + } 633 + } 634 + } 635 + function Ee(e, t) { 636 + return ce( 637 + T(e, function (e) { 638 + return te(ce(e), t) != null; 639 + }), 640 + ); 641 + } 642 + function Ce(e) { 643 + const t = re(e, "hx-target"); 644 + if (t) { 645 + if (t === "this") { 646 + return Ee(e, "hx-target"); 647 + } else { 648 + return ae(e, t); 649 + } 650 + } else { 651 + const n = ie(e); 652 + if (n.boosted) { 653 + return ne().body; 654 + } else { 655 + return e; 656 + } 657 + } 658 + } 659 + function Oe(t) { 660 + const n = Q.config.attributesToSettle; 661 + for (let e = 0; e < n.length; e++) { 662 + if (t === n[e]) { 663 + return true; 664 + } 665 + } 666 + return false; 667 + } 668 + function Re(t, n) { 669 + se(t.attributes, function (e) { 670 + if (!n.hasAttribute(e.name) && Oe(e.name)) { 671 + t.removeAttribute(e.name); 672 + } 673 + }); 674 + se(n.attributes, function (e) { 675 + if (Oe(e.name)) { 676 + t.setAttribute(e.name, e.value); 677 + } 678 + }); 679 + } 680 + function He(t, e) { 681 + const n = jn(e); 682 + for (let e = 0; e < n.length; e++) { 683 + const r = n[e]; 684 + try { 685 + if (r.isInlineSwap(t)) { 686 + return true; 687 + } 688 + } catch (e) { 689 + w(e); 690 + } 691 + } 692 + return t === "outerHTML"; 693 + } 694 + function Te(e, o, i) { 695 + let t = "#" + ee(o, "id"); 696 + let s = "outerHTML"; 697 + if (e === "true") { 698 + } else if (e.indexOf(":") > 0) { 699 + s = e.substr(0, e.indexOf(":")); 700 + t = e.substr(e.indexOf(":") + 1, e.length); 701 + } else { 702 + s = e; 703 + } 704 + const n = ne().querySelectorAll(t); 705 + if (n) { 706 + se(n, function (e) { 707 + let t; 708 + const n = o.cloneNode(true); 709 + t = ne().createDocumentFragment(); 710 + t.appendChild(n); 711 + if (!He(s, e)) { 712 + t = d(n); 713 + } 714 + const r = { shouldSwap: true, target: e, fragment: t }; 715 + if (!de(e, "htmx:oobBeforeSwap", r)) return; 716 + e = r.target; 717 + if (r.shouldSwap) { 718 + _e(s, e, e, t, i); 719 + } 720 + se(i.elts, function (e) { 721 + de(e, "htmx:oobAfterSwap", r); 722 + }); 723 + }); 724 + o.parentNode.removeChild(o); 725 + } else { 726 + o.parentNode.removeChild(o); 727 + fe(ne().body, "htmx:oobErrorNoTarget", { content: o }); 728 + } 729 + return e; 730 + } 731 + function qe(e) { 732 + se(p(e, "[hx-preserve], [data-hx-preserve]"), function (e) { 733 + const t = te(e, "id"); 734 + const n = ne().getElementById(t); 735 + if (n != null) { 736 + e.parentNode.replaceChild(n, e); 737 + } 738 + }); 739 + } 740 + function Le(l, e, u) { 741 + se(e.querySelectorAll("[id]"), function (t) { 742 + const n = ee(t, "id"); 743 + if (n && n.length > 0) { 744 + const r = n.replace("'", "\\'"); 745 + const o = t.tagName.replace(":", "\\:"); 746 + const e = d(l); 747 + const i = e && e.querySelector(o + "[id='" + r + "']"); 748 + if (i && i !== e) { 749 + const s = t.cloneNode(); 750 + Re(t, i); 751 + u.tasks.push(function () { 752 + Re(t, s); 753 + }); 754 + } 755 + } 756 + }); 757 + } 758 + function Ne(e) { 759 + return function () { 760 + o(e, Q.config.addedClass); 761 + Dt(ce(e)); 762 + Ae(d(e)); 763 + de(e, "htmx:load"); 764 + }; 765 + } 766 + function Ae(e) { 767 + const t = "[autofocus]"; 768 + const n = G(f(e, t) ? e : e.querySelector(t)); 769 + if (n != null) { 770 + n.focus(); 771 + } 772 + } 773 + function c(e, t, n, r) { 774 + Le(e, n, r); 775 + while (n.childNodes.length > 0) { 776 + const o = n.firstChild; 777 + Y(ce(o), Q.config.addedClass); 778 + e.insertBefore(o, t); 779 + if (o.nodeType !== Node.TEXT_NODE && o.nodeType !== Node.COMMENT_NODE) { 780 + r.tasks.push(Ne(o)); 781 + } 782 + } 783 + } 784 + function Ie(e, t) { 785 + let n = 0; 786 + while (n < e.length) { 787 + t = ((t << 5) - t + e.charCodeAt(n++)) | 0; 788 + } 789 + return t; 790 + } 791 + function Pe(t) { 792 + let n = 0; 793 + if (t.attributes) { 794 + for (let e = 0; e < t.attributes.length; e++) { 795 + const r = t.attributes[e]; 796 + if (r.value) { 797 + n = Ie(r.name, n); 798 + n = Ie(r.value, n); 799 + } 800 + } 801 + } 802 + return n; 803 + } 804 + function ke(t) { 805 + const n = ie(t); 806 + if (n.onHandlers) { 807 + for (let e = 0; e < n.onHandlers.length; e++) { 808 + const r = n.onHandlers[e]; 809 + we(t, r.event, r.listener); 810 + } 811 + delete n.onHandlers; 812 + } 813 + } 814 + function De(e) { 815 + const t = ie(e); 816 + if (t.timeout) { 817 + clearTimeout(t.timeout); 818 + } 819 + if (t.listenerInfos) { 820 + se(t.listenerInfos, function (e) { 821 + if (e.on) { 822 + we(e.on, e.trigger, e.listener); 823 + } 824 + }); 825 + } 826 + ke(e); 827 + se(Object.keys(t), function (e) { 828 + delete t[e]; 829 + }); 830 + } 831 + function a(e) { 832 + de(e, "htmx:beforeCleanupElement"); 833 + De(e); 834 + if (e.children) { 835 + se(e.children, function (e) { 836 + a(e); 837 + }); 838 + } 839 + } 840 + function Me(t, e, n) { 841 + if (t instanceof Element && t.tagName === "BODY") { 842 + return Ve(t, e, n); 843 + } 844 + let r; 845 + const o = t.previousSibling; 846 + c(u(t), t, e, n); 847 + if (o == null) { 848 + r = u(t).firstChild; 849 + } else { 850 + r = o.nextSibling; 851 + } 852 + n.elts = n.elts.filter(function (e) { 853 + return e !== t; 854 + }); 855 + while (r && r !== t) { 856 + if (r instanceof Element) { 857 + n.elts.push(r); 858 + } 859 + r = r.nextSibling; 860 + } 861 + a(t); 862 + if (t instanceof Element) { 863 + t.remove(); 864 + } else { 865 + t.parentNode.removeChild(t); 866 + } 867 + } 868 + function Xe(e, t, n) { 869 + return c(e, e.firstChild, t, n); 870 + } 871 + function Fe(e, t, n) { 872 + return c(u(e), e, t, n); 873 + } 874 + function Be(e, t, n) { 875 + return c(e, null, t, n); 876 + } 877 + function Ue(e, t, n) { 878 + return c(u(e), e.nextSibling, t, n); 879 + } 880 + function je(e) { 881 + a(e); 882 + return u(e).removeChild(e); 883 + } 884 + function Ve(e, t, n) { 885 + const r = e.firstChild; 886 + c(e, r, t, n); 887 + if (r) { 888 + while (r.nextSibling) { 889 + a(r.nextSibling); 890 + e.removeChild(r.nextSibling); 891 + } 892 + a(r); 893 + e.removeChild(r); 894 + } 895 + } 896 + function _e(t, e, n, r, o) { 897 + switch (t) { 898 + case "none": 899 + return; 900 + case "outerHTML": 901 + Me(n, r, o); 902 + return; 903 + case "afterbegin": 904 + Xe(n, r, o); 905 + return; 906 + case "beforebegin": 907 + Fe(n, r, o); 908 + return; 909 + case "beforeend": 910 + Be(n, r, o); 911 + return; 912 + case "afterend": 913 + Ue(n, r, o); 914 + return; 915 + case "delete": 916 + je(n); 917 + return; 918 + default: 919 + var i = jn(e); 920 + for (let e = 0; e < i.length; e++) { 921 + const s = i[e]; 922 + try { 923 + const l = s.handleSwap(t, n, r, o); 924 + if (l) { 925 + if (Array.isArray(l)) { 926 + for (let e = 0; e < l.length; e++) { 927 + const u = l[e]; 928 + if ( 929 + u.nodeType !== Node.TEXT_NODE && 930 + u.nodeType !== Node.COMMENT_NODE 931 + ) { 932 + o.tasks.push(Ne(u)); 933 + } 934 + } 935 + } 936 + return; 937 + } 938 + } catch (e) { 939 + w(e); 940 + } 941 + } 942 + if (t === "innerHTML") { 943 + Ve(n, r, o); 944 + } else { 945 + _e(Q.config.defaultSwapStyle, e, n, r, o); 946 + } 947 + } 948 + } 949 + function $e(e, n) { 950 + var t = p(e, "[hx-swap-oob], [data-hx-swap-oob]"); 951 + se(t, function (e) { 952 + if (Q.config.allowNestedOobSwaps || e.parentElement === null) { 953 + const t = te(e, "hx-swap-oob"); 954 + if (t != null) { 955 + Te(t, e, n); 956 + } 957 + } else { 958 + e.removeAttribute("hx-swap-oob"); 959 + e.removeAttribute("data-hx-swap-oob"); 960 + } 961 + }); 962 + return t.length > 0; 963 + } 964 + function ze(e, t, r, o) { 965 + if (!o) { 966 + o = {}; 967 + } 968 + e = y(e); 969 + const n = document.activeElement; 970 + let i = {}; 971 + try { 972 + i = { 973 + elt: n, 974 + start: n ? n.selectionStart : null, 975 + end: n ? n.selectionEnd : null, 976 + }; 977 + } catch (e) {} 978 + const s = xn(e); 979 + if (r.swapStyle === "textContent") { 980 + e.textContent = t; 981 + } else { 982 + let n = D(t); 983 + s.title = n.title; 984 + if (o.selectOOB) { 985 + const u = o.selectOOB.split(","); 986 + for (let t = 0; t < u.length; t++) { 987 + const c = u[t].split(":", 2); 988 + let e = c[0].trim(); 989 + if (e.indexOf("#") === 0) { 990 + e = e.substring(1); 991 + } 992 + const a = c[1] || "true"; 993 + const f = n.querySelector("#" + e); 994 + if (f) { 995 + Te(a, f, s); 996 + } 997 + } 998 + } 999 + $e(n, s); 1000 + se(p(n, "template"), function (e) { 1001 + if ($e(e.content, s)) { 1002 + e.remove(); 1003 + } 1004 + }); 1005 + if (o.select) { 1006 + const d = ne().createDocumentFragment(); 1007 + se(n.querySelectorAll(o.select), function (e) { 1008 + d.appendChild(e); 1009 + }); 1010 + n = d; 1011 + } 1012 + qe(n); 1013 + _e(r.swapStyle, o.contextElement, e, n, s); 1014 + } 1015 + if (i.elt && !le(i.elt) && ee(i.elt, "id")) { 1016 + const h = document.getElementById(ee(i.elt, "id")); 1017 + const g = { 1018 + preventScroll: 1019 + r.focusScroll !== undefined 1020 + ? !r.focusScroll 1021 + : !Q.config.defaultFocusScroll, 1022 + }; 1023 + if (h) { 1024 + if (i.start && h.setSelectionRange) { 1025 + try { 1026 + h.setSelectionRange(i.start, i.end); 1027 + } catch (e) {} 1028 + } 1029 + h.focus(g); 1030 + } 1031 + } 1032 + e.classList.remove(Q.config.swappingClass); 1033 + se(s.elts, function (e) { 1034 + if (e.classList) { 1035 + e.classList.add(Q.config.settlingClass); 1036 + } 1037 + de(e, "htmx:afterSwap", o.eventInfo); 1038 + }); 1039 + if (o.afterSwapCallback) { 1040 + o.afterSwapCallback(); 1041 + } 1042 + if (!r.ignoreTitle) { 1043 + Dn(s.title); 1044 + } 1045 + const l = function () { 1046 + se(s.tasks, function (e) { 1047 + e.call(); 1048 + }); 1049 + se(s.elts, function (e) { 1050 + if (e.classList) { 1051 + e.classList.remove(Q.config.settlingClass); 1052 + } 1053 + de(e, "htmx:afterSettle", o.eventInfo); 1054 + }); 1055 + if (o.anchor) { 1056 + const e = ce(y("#" + o.anchor)); 1057 + if (e) { 1058 + e.scrollIntoView({ block: "start", behavior: "auto" }); 1059 + } 1060 + } 1061 + bn(s.elts, r); 1062 + if (o.afterSettleCallback) { 1063 + o.afterSettleCallback(); 1064 + } 1065 + }; 1066 + if (r.settleDelay > 0) { 1067 + E().setTimeout(l, r.settleDelay); 1068 + } else { 1069 + l(); 1070 + } 1071 + } 1072 + function Je(e, t, n) { 1073 + const r = e.getResponseHeader(t); 1074 + if (r.indexOf("{") === 0) { 1075 + const o = S(r); 1076 + for (const i in o) { 1077 + if (o.hasOwnProperty(i)) { 1078 + let e = o[i]; 1079 + if (X(e)) { 1080 + n = e.target !== undefined ? e.target : n; 1081 + } else { 1082 + e = { value: e }; 1083 + } 1084 + de(n, i, e); 1085 + } 1086 + } 1087 + } else { 1088 + const s = r.split(","); 1089 + for (let e = 0; e < s.length; e++) { 1090 + de(n, s[e].trim(), []); 1091 + } 1092 + } 1093 + } 1094 + const Ke = /\s/; 1095 + const x = /[\s,]/; 1096 + const Ge = /[_$a-zA-Z]/; 1097 + const Ze = /[_$a-zA-Z0-9]/; 1098 + const Ye = ['"', "'", "/"]; 1099 + const We = /[^\s]/; 1100 + const Qe = /[{(]/; 1101 + const et = /[})]/; 1102 + function tt(e) { 1103 + const t = []; 1104 + let n = 0; 1105 + while (n < e.length) { 1106 + if (Ge.exec(e.charAt(n))) { 1107 + var r = n; 1108 + while (Ze.exec(e.charAt(n + 1))) { 1109 + n++; 1110 + } 1111 + t.push(e.substr(r, n - r + 1)); 1112 + } else if (Ye.indexOf(e.charAt(n)) !== -1) { 1113 + const o = e.charAt(n); 1114 + var r = n; 1115 + n++; 1116 + while (n < e.length && e.charAt(n) !== o) { 1117 + if (e.charAt(n) === "\\") { 1118 + n++; 1119 + } 1120 + n++; 1121 + } 1122 + t.push(e.substr(r, n - r + 1)); 1123 + } else { 1124 + const i = e.charAt(n); 1125 + t.push(i); 1126 + } 1127 + n++; 1128 + } 1129 + return t; 1130 + } 1131 + function nt(e, t, n) { 1132 + return ( 1133 + Ge.exec(e.charAt(0)) && 1134 + e !== "true" && 1135 + e !== "false" && 1136 + e !== "this" && 1137 + e !== n && 1138 + t !== "." 1139 + ); 1140 + } 1141 + function rt(r, o, i) { 1142 + if (o[0] === "[") { 1143 + o.shift(); 1144 + let e = 1; 1145 + let t = " return (function(" + i + "){ return ("; 1146 + let n = null; 1147 + while (o.length > 0) { 1148 + const s = o[0]; 1149 + if (s === "]") { 1150 + e--; 1151 + if (e === 0) { 1152 + if (n === null) { 1153 + t = t + "true"; 1154 + } 1155 + o.shift(); 1156 + t += ")})"; 1157 + try { 1158 + const l = vn( 1159 + r, 1160 + function () { 1161 + return Function(t)(); 1162 + }, 1163 + function () { 1164 + return true; 1165 + }, 1166 + ); 1167 + l.source = t; 1168 + return l; 1169 + } catch (e) { 1170 + fe(ne().body, "htmx:syntax:error", { error: e, source: t }); 1171 + return null; 1172 + } 1173 + } 1174 + } else if (s === "[") { 1175 + e++; 1176 + } 1177 + if (nt(s, n, i)) { 1178 + t += 1179 + "((" + 1180 + i + 1181 + "." + 1182 + s + 1183 + ") ? (" + 1184 + i + 1185 + "." + 1186 + s + 1187 + ") : (window." + 1188 + s + 1189 + "))"; 1190 + } else { 1191 + t = t + s; 1192 + } 1193 + n = o.shift(); 1194 + } 1195 + } 1196 + } 1197 + function b(e, t) { 1198 + let n = ""; 1199 + while (e.length > 0 && !t.test(e[0])) { 1200 + n += e.shift(); 1201 + } 1202 + return n; 1203 + } 1204 + function ot(e) { 1205 + let t; 1206 + if (e.length > 0 && Qe.test(e[0])) { 1207 + e.shift(); 1208 + t = b(e, et).trim(); 1209 + e.shift(); 1210 + } else { 1211 + t = b(e, x); 1212 + } 1213 + return t; 1214 + } 1215 + const it = "input, textarea, select"; 1216 + function st(e, t, n) { 1217 + const r = []; 1218 + const o = tt(t); 1219 + do { 1220 + b(o, We); 1221 + const l = o.length; 1222 + const u = b(o, /[,\[\s]/); 1223 + if (u !== "") { 1224 + if (u === "every") { 1225 + const c = { trigger: "every" }; 1226 + b(o, We); 1227 + c.pollInterval = h(b(o, /[,\[\s]/)); 1228 + b(o, We); 1229 + var i = rt(e, o, "event"); 1230 + if (i) { 1231 + c.eventFilter = i; 1232 + } 1233 + r.push(c); 1234 + } else { 1235 + const a = { trigger: u }; 1236 + var i = rt(e, o, "event"); 1237 + if (i) { 1238 + a.eventFilter = i; 1239 + } 1240 + while (o.length > 0 && o[0] !== ",") { 1241 + b(o, We); 1242 + const f = o.shift(); 1243 + if (f === "changed") { 1244 + a.changed = true; 1245 + } else if (f === "once") { 1246 + a.once = true; 1247 + } else if (f === "consume") { 1248 + a.consume = true; 1249 + } else if (f === "delay" && o[0] === ":") { 1250 + o.shift(); 1251 + a.delay = h(b(o, x)); 1252 + } else if (f === "from" && o[0] === ":") { 1253 + o.shift(); 1254 + if (Qe.test(o[0])) { 1255 + var s = ot(o); 1256 + } else { 1257 + var s = b(o, x); 1258 + if ( 1259 + s === "closest" || 1260 + s === "find" || 1261 + s === "next" || 1262 + s === "previous" 1263 + ) { 1264 + o.shift(); 1265 + const d = ot(o); 1266 + if (d.length > 0) { 1267 + s += " " + d; 1268 + } 1269 + } 1270 + } 1271 + a.from = s; 1272 + } else if (f === "target" && o[0] === ":") { 1273 + o.shift(); 1274 + a.target = ot(o); 1275 + } else if (f === "throttle" && o[0] === ":") { 1276 + o.shift(); 1277 + a.throttle = h(b(o, x)); 1278 + } else if (f === "queue" && o[0] === ":") { 1279 + o.shift(); 1280 + a.queue = b(o, x); 1281 + } else if (f === "root" && o[0] === ":") { 1282 + o.shift(); 1283 + a[f] = ot(o); 1284 + } else if (f === "threshold" && o[0] === ":") { 1285 + o.shift(); 1286 + a[f] = b(o, x); 1287 + } else { 1288 + fe(e, "htmx:syntax:error", { token: o.shift() }); 1289 + } 1290 + } 1291 + r.push(a); 1292 + } 1293 + } 1294 + if (o.length === l) { 1295 + fe(e, "htmx:syntax:error", { token: o.shift() }); 1296 + } 1297 + b(o, We); 1298 + } while (o[0] === "," && o.shift()); 1299 + if (n) { 1300 + n[t] = r; 1301 + } 1302 + return r; 1303 + } 1304 + function lt(e) { 1305 + const t = te(e, "hx-trigger"); 1306 + let n = []; 1307 + if (t) { 1308 + const r = Q.config.triggerSpecsCache; 1309 + n = (r && r[t]) || st(e, t, r); 1310 + } 1311 + if (n.length > 0) { 1312 + return n; 1313 + } else if (f(e, "form")) { 1314 + return [{ trigger: "submit" }]; 1315 + } else if (f(e, 'input[type="button"], input[type="submit"]')) { 1316 + return [{ trigger: "click" }]; 1317 + } else if (f(e, it)) { 1318 + return [{ trigger: "change" }]; 1319 + } else { 1320 + return [{ trigger: "click" }]; 1321 + } 1322 + } 1323 + function ut(e) { 1324 + ie(e).cancelled = true; 1325 + } 1326 + function ct(e, t, n) { 1327 + const r = ie(e); 1328 + r.timeout = E().setTimeout(function () { 1329 + if (le(e) && r.cancelled !== true) { 1330 + if (!pt(n, e, Xt("hx:poll:trigger", { triggerSpec: n, target: e }))) { 1331 + t(e); 1332 + } 1333 + ct(e, t, n); 1334 + } 1335 + }, n.pollInterval); 1336 + } 1337 + function at(e) { 1338 + return ( 1339 + location.hostname === e.hostname && 1340 + ee(e, "href") && 1341 + ee(e, "href").indexOf("#") !== 0 1342 + ); 1343 + } 1344 + function ft(e) { 1345 + return g(e, Q.config.disableSelector); 1346 + } 1347 + function dt(t, n, e) { 1348 + if ( 1349 + (t instanceof HTMLAnchorElement && 1350 + at(t) && 1351 + (t.target === "" || t.target === "_self")) || 1352 + (t.tagName === "FORM" && 1353 + String(ee(t, "method")).toLowerCase() !== "dialog") 1354 + ) { 1355 + n.boosted = true; 1356 + let r, o; 1357 + if (t.tagName === "A") { 1358 + r = "get"; 1359 + o = ee(t, "href"); 1360 + } else { 1361 + const i = ee(t, "method"); 1362 + r = i ? i.toLowerCase() : "get"; 1363 + if (r === "get") { 1364 + } 1365 + o = ee(t, "action"); 1366 + } 1367 + e.forEach(function (e) { 1368 + mt( 1369 + t, 1370 + function (e, t) { 1371 + const n = ce(e); 1372 + if (ft(n)) { 1373 + a(n); 1374 + return; 1375 + } 1376 + he(r, o, n, t); 1377 + }, 1378 + n, 1379 + e, 1380 + true, 1381 + ); 1382 + }); 1383 + } 1384 + } 1385 + function ht(e, t) { 1386 + const n = ce(t); 1387 + if (!n) { 1388 + return false; 1389 + } 1390 + if (e.type === "submit" || e.type === "click") { 1391 + if (n.tagName === "FORM") { 1392 + return true; 1393 + } 1394 + if (f(n, 'input[type="submit"], button') && g(n, "form") !== null) { 1395 + return true; 1396 + } 1397 + if ( 1398 + n instanceof HTMLAnchorElement && 1399 + n.href && 1400 + (n.getAttribute("href") === "#" || 1401 + n.getAttribute("href").indexOf("#") !== 0) 1402 + ) { 1403 + return true; 1404 + } 1405 + } 1406 + return false; 1407 + } 1408 + function gt(e, t) { 1409 + return ( 1410 + ie(e).boosted && 1411 + e instanceof HTMLAnchorElement && 1412 + t.type === "click" && 1413 + (t.ctrlKey || t.metaKey) 1414 + ); 1415 + } 1416 + function pt(e, t, n) { 1417 + const r = e.eventFilter; 1418 + if (r) { 1419 + try { 1420 + return r.call(t, n) !== true; 1421 + } catch (e) { 1422 + const o = r.source; 1423 + fe(ne().body, "htmx:eventFilter:error", { error: e, source: o }); 1424 + return true; 1425 + } 1426 + } 1427 + return false; 1428 + } 1429 + function mt(s, l, e, u, c) { 1430 + const a = ie(s); 1431 + let t; 1432 + if (u.from) { 1433 + t = m(s, u.from); 1434 + } else { 1435 + t = [s]; 1436 + } 1437 + if (u.changed) { 1438 + t.forEach(function (e) { 1439 + const t = ie(e); 1440 + t.lastValue = e.value; 1441 + }); 1442 + } 1443 + se(t, function (o) { 1444 + const i = function (e) { 1445 + if (!le(s)) { 1446 + o.removeEventListener(u.trigger, i); 1447 + return; 1448 + } 1449 + if (gt(s, e)) { 1450 + return; 1451 + } 1452 + if (c || ht(e, s)) { 1453 + e.preventDefault(); 1454 + } 1455 + if (pt(u, s, e)) { 1456 + return; 1457 + } 1458 + const t = ie(e); 1459 + t.triggerSpec = u; 1460 + if (t.handledFor == null) { 1461 + t.handledFor = []; 1462 + } 1463 + if (t.handledFor.indexOf(s) < 0) { 1464 + t.handledFor.push(s); 1465 + if (u.consume) { 1466 + e.stopPropagation(); 1467 + } 1468 + if (u.target && e.target) { 1469 + if (!f(ce(e.target), u.target)) { 1470 + return; 1471 + } 1472 + } 1473 + if (u.once) { 1474 + if (a.triggeredOnce) { 1475 + return; 1476 + } else { 1477 + a.triggeredOnce = true; 1478 + } 1479 + } 1480 + if (u.changed) { 1481 + const n = ie(o); 1482 + const r = o.value; 1483 + if (n.lastValue === r) { 1484 + return; 1485 + } 1486 + n.lastValue = r; 1487 + } 1488 + if (a.delayed) { 1489 + clearTimeout(a.delayed); 1490 + } 1491 + if (a.throttle) { 1492 + return; 1493 + } 1494 + if (u.throttle > 0) { 1495 + if (!a.throttle) { 1496 + de(s, "htmx:trigger"); 1497 + l(s, e); 1498 + a.throttle = E().setTimeout(function () { 1499 + a.throttle = null; 1500 + }, u.throttle); 1501 + } 1502 + } else if (u.delay > 0) { 1503 + a.delayed = E().setTimeout(function () { 1504 + de(s, "htmx:trigger"); 1505 + l(s, e); 1506 + }, u.delay); 1507 + } else { 1508 + de(s, "htmx:trigger"); 1509 + l(s, e); 1510 + } 1511 + } 1512 + }; 1513 + if (e.listenerInfos == null) { 1514 + e.listenerInfos = []; 1515 + } 1516 + e.listenerInfos.push({ trigger: u.trigger, listener: i, on: o }); 1517 + o.addEventListener(u.trigger, i); 1518 + }); 1519 + } 1520 + let yt = false; 1521 + let xt = null; 1522 + function bt() { 1523 + if (!xt) { 1524 + xt = function () { 1525 + yt = true; 1526 + }; 1527 + window.addEventListener("scroll", xt); 1528 + setInterval(function () { 1529 + if (yt) { 1530 + yt = false; 1531 + se( 1532 + ne().querySelectorAll( 1533 + "[hx-trigger*='revealed'],[data-hx-trigger*='revealed']", 1534 + ), 1535 + function (e) { 1536 + wt(e); 1537 + }, 1538 + ); 1539 + } 1540 + }, 200); 1541 + } 1542 + } 1543 + function wt(e) { 1544 + if (!s(e, "data-hx-revealed") && B(e)) { 1545 + e.setAttribute("data-hx-revealed", "true"); 1546 + const t = ie(e); 1547 + if (t.initHash) { 1548 + de(e, "revealed"); 1549 + } else { 1550 + e.addEventListener( 1551 + "htmx:afterProcessNode", 1552 + function () { 1553 + de(e, "revealed"); 1554 + }, 1555 + { once: true }, 1556 + ); 1557 + } 1558 + } 1559 + } 1560 + function vt(e, t, n, r) { 1561 + const o = function () { 1562 + if (!n.loaded) { 1563 + n.loaded = true; 1564 + t(e); 1565 + } 1566 + }; 1567 + if (r > 0) { 1568 + E().setTimeout(o, r); 1569 + } else { 1570 + o(); 1571 + } 1572 + } 1573 + function St(t, n, e) { 1574 + let i = false; 1575 + se(v, function (r) { 1576 + if (s(t, "hx-" + r)) { 1577 + const o = te(t, "hx-" + r); 1578 + i = true; 1579 + n.path = o; 1580 + n.verb = r; 1581 + e.forEach(function (e) { 1582 + Et(t, e, n, function (e, t) { 1583 + const n = ce(e); 1584 + if (g(n, Q.config.disableSelector)) { 1585 + a(n); 1586 + return; 1587 + } 1588 + he(r, o, n, t); 1589 + }); 1590 + }); 1591 + } 1592 + }); 1593 + return i; 1594 + } 1595 + function Et(r, e, t, n) { 1596 + if (e.trigger === "revealed") { 1597 + bt(); 1598 + mt(r, n, t, e); 1599 + wt(ce(r)); 1600 + } else if (e.trigger === "intersect") { 1601 + const o = {}; 1602 + if (e.root) { 1603 + o.root = ae(r, e.root); 1604 + } 1605 + if (e.threshold) { 1606 + o.threshold = parseFloat(e.threshold); 1607 + } 1608 + const i = new IntersectionObserver(function (t) { 1609 + for (let e = 0; e < t.length; e++) { 1610 + const n = t[e]; 1611 + if (n.isIntersecting) { 1612 + de(r, "intersect"); 1613 + break; 1614 + } 1615 + } 1616 + }, o); 1617 + i.observe(ce(r)); 1618 + mt(ce(r), n, t, e); 1619 + } else if (e.trigger === "load") { 1620 + if (!pt(e, r, Xt("load", { elt: r }))) { 1621 + vt(ce(r), n, t, e.delay); 1622 + } 1623 + } else if (e.pollInterval > 0) { 1624 + t.polling = true; 1625 + ct(ce(r), n, e); 1626 + } else { 1627 + mt(r, n, t, e); 1628 + } 1629 + } 1630 + function Ct(e) { 1631 + const t = ce(e); 1632 + if (!t) { 1633 + return false; 1634 + } 1635 + const n = t.attributes; 1636 + for (let e = 0; e < n.length; e++) { 1637 + const r = n[e].name; 1638 + if ( 1639 + l(r, "hx-on:") || 1640 + l(r, "data-hx-on:") || 1641 + l(r, "hx-on-") || 1642 + l(r, "data-hx-on-") 1643 + ) { 1644 + return true; 1645 + } 1646 + } 1647 + return false; 1648 + } 1649 + const Ot = new XPathEvaluator().createExpression( 1650 + './/*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or' + 1651 + ' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]', 1652 + ); 1653 + function Rt(e, t) { 1654 + if (Ct(e)) { 1655 + t.push(ce(e)); 1656 + } 1657 + const n = Ot.evaluate(e); 1658 + let r = null; 1659 + while ((r = n.iterateNext())) t.push(ce(r)); 1660 + } 1661 + function Ht(e) { 1662 + const t = []; 1663 + if (e instanceof DocumentFragment) { 1664 + for (const n of e.childNodes) { 1665 + Rt(n, t); 1666 + } 1667 + } else { 1668 + Rt(e, t); 1669 + } 1670 + return t; 1671 + } 1672 + function Tt(e) { 1673 + if (e.querySelectorAll) { 1674 + const n = 1675 + ", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]"; 1676 + const r = []; 1677 + for (const i in Xn) { 1678 + const s = Xn[i]; 1679 + if (s.getSelectors) { 1680 + var t = s.getSelectors(); 1681 + if (t) { 1682 + r.push(t); 1683 + } 1684 + } 1685 + } 1686 + const o = e.querySelectorAll( 1687 + O + 1688 + n + 1689 + ", form, [type='submit']," + 1690 + " [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]" + 1691 + r 1692 + .flat() 1693 + .map((e) => ", " + e) 1694 + .join(""), 1695 + ); 1696 + return o; 1697 + } else { 1698 + return []; 1699 + } 1700 + } 1701 + function qt(e) { 1702 + const t = g(ce(e.target), "button, input[type='submit']"); 1703 + const n = Nt(e); 1704 + if (n) { 1705 + n.lastButtonClicked = t; 1706 + } 1707 + } 1708 + function Lt(e) { 1709 + const t = Nt(e); 1710 + if (t) { 1711 + t.lastButtonClicked = null; 1712 + } 1713 + } 1714 + function Nt(e) { 1715 + const t = g(ce(e.target), "button, input[type='submit']"); 1716 + if (!t) { 1717 + return; 1718 + } 1719 + const n = y("#" + ee(t, "form"), t.getRootNode()) || g(t, "form"); 1720 + if (!n) { 1721 + return; 1722 + } 1723 + return ie(n); 1724 + } 1725 + function At(e) { 1726 + e.addEventListener("click", qt); 1727 + e.addEventListener("focusin", qt); 1728 + e.addEventListener("focusout", Lt); 1729 + } 1730 + function It(t, e, n) { 1731 + const r = ie(t); 1732 + if (!Array.isArray(r.onHandlers)) { 1733 + r.onHandlers = []; 1734 + } 1735 + let o; 1736 + const i = function (e) { 1737 + vn(t, function () { 1738 + if (ft(t)) { 1739 + return; 1740 + } 1741 + if (!o) { 1742 + o = new Function("event", n); 1743 + } 1744 + o.call(t, e); 1745 + }); 1746 + }; 1747 + t.addEventListener(e, i); 1748 + r.onHandlers.push({ event: e, listener: i }); 1749 + } 1750 + function Pt(t) { 1751 + ke(t); 1752 + for (let e = 0; e < t.attributes.length; e++) { 1753 + const n = t.attributes[e].name; 1754 + const r = t.attributes[e].value; 1755 + if (l(n, "hx-on") || l(n, "data-hx-on")) { 1756 + const o = n.indexOf("-on") + 3; 1757 + const i = n.slice(o, o + 1); 1758 + if (i === "-" || i === ":") { 1759 + let e = n.slice(o + 1); 1760 + if (l(e, ":")) { 1761 + e = "htmx" + e; 1762 + } else if (l(e, "-")) { 1763 + e = "htmx:" + e.slice(1); 1764 + } else if (l(e, "htmx-")) { 1765 + e = "htmx:" + e.slice(5); 1766 + } 1767 + It(t, e, r); 1768 + } 1769 + } 1770 + } 1771 + } 1772 + function kt(t) { 1773 + if (g(t, Q.config.disableSelector)) { 1774 + a(t); 1775 + return; 1776 + } 1777 + const n = ie(t); 1778 + if (n.initHash !== Pe(t)) { 1779 + De(t); 1780 + n.initHash = Pe(t); 1781 + de(t, "htmx:beforeProcessNode"); 1782 + if (t.value) { 1783 + n.lastValue = t.value; 1784 + } 1785 + const e = lt(t); 1786 + const r = St(t, n, e); 1787 + if (!r) { 1788 + if (re(t, "hx-boost") === "true") { 1789 + dt(t, n, e); 1790 + } else if (s(t, "hx-trigger")) { 1791 + e.forEach(function (e) { 1792 + Et(t, e, n, function () {}); 1793 + }); 1794 + } 1795 + } 1796 + if ( 1797 + t.tagName === "FORM" || 1798 + (ee(t, "type") === "submit" && s(t, "form")) 1799 + ) { 1800 + At(t); 1801 + } 1802 + de(t, "htmx:afterProcessNode"); 1803 + } 1804 + } 1805 + function Dt(e) { 1806 + e = y(e); 1807 + if (g(e, Q.config.disableSelector)) { 1808 + a(e); 1809 + return; 1810 + } 1811 + kt(e); 1812 + se(Tt(e), function (e) { 1813 + kt(e); 1814 + }); 1815 + se(Ht(e), Pt); 1816 + } 1817 + function Mt(e) { 1818 + return e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); 1819 + } 1820 + function Xt(e, t) { 1821 + let n; 1822 + if (window.CustomEvent && typeof window.CustomEvent === "function") { 1823 + n = new CustomEvent(e, { 1824 + bubbles: true, 1825 + cancelable: true, 1826 + composed: true, 1827 + detail: t, 1828 + }); 1829 + } else { 1830 + n = ne().createEvent("CustomEvent"); 1831 + n.initCustomEvent(e, true, true, t); 1832 + } 1833 + return n; 1834 + } 1835 + function fe(e, t, n) { 1836 + de(e, t, ue({ error: t }, n)); 1837 + } 1838 + function Ft(e) { 1839 + return e === "htmx:afterProcessNode"; 1840 + } 1841 + function Bt(e, t) { 1842 + se(jn(e), function (e) { 1843 + try { 1844 + t(e); 1845 + } catch (e) { 1846 + w(e); 1847 + } 1848 + }); 1849 + } 1850 + function w(e) { 1851 + if (console.error) { 1852 + console.error(e); 1853 + } else if (console.log) { 1854 + console.log("ERROR: ", e); 1855 + } 1856 + } 1857 + function de(e, t, n) { 1858 + e = y(e); 1859 + if (n == null) { 1860 + n = {}; 1861 + } 1862 + n.elt = e; 1863 + const r = Xt(t, n); 1864 + if (Q.logger && !Ft(t)) { 1865 + Q.logger(e, t, n); 1866 + } 1867 + if (n.error) { 1868 + w(n.error); 1869 + de(e, "htmx:error", { errorInfo: n }); 1870 + } 1871 + let o = e.dispatchEvent(r); 1872 + const i = Mt(t); 1873 + if (o && i !== t) { 1874 + const s = Xt(i, r.detail); 1875 + o = o && e.dispatchEvent(s); 1876 + } 1877 + Bt(ce(e), function (e) { 1878 + o = o && e.onEvent(t, r) !== false && !r.defaultPrevented; 1879 + }); 1880 + return o; 1881 + } 1882 + let Ut = location.pathname + location.search; 1883 + function jt() { 1884 + const e = ne().querySelector("[hx-history-elt],[data-hx-history-elt]"); 1885 + return e || ne().body; 1886 + } 1887 + function Vt(t, e) { 1888 + if (!j()) { 1889 + return; 1890 + } 1891 + const n = $t(e); 1892 + const r = ne().title; 1893 + const o = window.scrollY; 1894 + if (Q.config.historyCacheSize <= 0) { 1895 + localStorage.removeItem("htmx-history-cache"); 1896 + return; 1897 + } 1898 + t = V(t); 1899 + const i = S(localStorage.getItem("htmx-history-cache")) || []; 1900 + for (let e = 0; e < i.length; e++) { 1901 + if (i[e].url === t) { 1902 + i.splice(e, 1); 1903 + break; 1904 + } 1905 + } 1906 + const s = { url: t, content: n, title: r, scroll: o }; 1907 + de(ne().body, "htmx:historyItemCreated", { item: s, cache: i }); 1908 + i.push(s); 1909 + while (i.length > Q.config.historyCacheSize) { 1910 + i.shift(); 1911 + } 1912 + while (i.length > 0) { 1913 + try { 1914 + localStorage.setItem("htmx-history-cache", JSON.stringify(i)); 1915 + break; 1916 + } catch (e) { 1917 + fe(ne().body, "htmx:historyCacheError", { cause: e, cache: i }); 1918 + i.shift(); 1919 + } 1920 + } 1921 + } 1922 + function _t(t) { 1923 + if (!j()) { 1924 + return null; 1925 + } 1926 + t = V(t); 1927 + const n = S(localStorage.getItem("htmx-history-cache")) || []; 1928 + for (let e = 0; e < n.length; e++) { 1929 + if (n[e].url === t) { 1930 + return n[e]; 1931 + } 1932 + } 1933 + return null; 1934 + } 1935 + function $t(e) { 1936 + const t = Q.config.requestClass; 1937 + const n = e.cloneNode(true); 1938 + se(p(n, "." + t), function (e) { 1939 + o(e, t); 1940 + }); 1941 + se(p(n, "[data-disabled-by-htmx]"), function (e) { 1942 + e.removeAttribute("disabled"); 1943 + }); 1944 + return n.innerHTML; 1945 + } 1946 + function zt() { 1947 + const e = jt(); 1948 + const t = Ut || location.pathname + location.search; 1949 + let n; 1950 + try { 1951 + n = ne().querySelector( 1952 + '[hx-history="false" i],[data-hx-history="false" i]', 1953 + ); 1954 + } catch (e) { 1955 + n = ne().querySelector('[hx-history="false"],[data-hx-history="false"]'); 1956 + } 1957 + if (!n) { 1958 + de(ne().body, "htmx:beforeHistorySave", { path: t, historyElt: e }); 1959 + Vt(t, e); 1960 + } 1961 + if (Q.config.historyEnabled) 1962 + history.replaceState({ htmx: true }, ne().title, window.location.href); 1963 + } 1964 + function Jt(e) { 1965 + if (Q.config.getCacheBusterParam) { 1966 + e = e.replace(/org\.htmx\.cache-buster=[^&]*&?/, ""); 1967 + if (pe(e, "&") || pe(e, "?")) { 1968 + e = e.slice(0, -1); 1969 + } 1970 + } 1971 + if (Q.config.historyEnabled) { 1972 + history.pushState({ htmx: true }, "", e); 1973 + } 1974 + Ut = e; 1975 + } 1976 + function Kt(e) { 1977 + if (Q.config.historyEnabled) history.replaceState({ htmx: true }, "", e); 1978 + Ut = e; 1979 + } 1980 + function Gt(e) { 1981 + se(e, function (e) { 1982 + e.call(undefined); 1983 + }); 1984 + } 1985 + function Zt(o) { 1986 + const e = new XMLHttpRequest(); 1987 + const i = { path: o, xhr: e }; 1988 + de(ne().body, "htmx:historyCacheMiss", i); 1989 + e.open("GET", o, true); 1990 + e.setRequestHeader("HX-Request", "true"); 1991 + e.setRequestHeader("HX-History-Restore-Request", "true"); 1992 + e.setRequestHeader("HX-Current-URL", ne().location.href); 1993 + e.onload = function () { 1994 + if (this.status >= 200 && this.status < 400) { 1995 + de(ne().body, "htmx:historyCacheMissLoad", i); 1996 + const e = D(this.response); 1997 + const t = 1998 + e.querySelector("[hx-history-elt],[data-hx-history-elt]") || e; 1999 + const n = jt(); 2000 + const r = xn(n); 2001 + Dn(e.title); 2002 + Ve(n, t, r); 2003 + Gt(r.tasks); 2004 + Ut = o; 2005 + de(ne().body, "htmx:historyRestore", { 2006 + path: o, 2007 + cacheMiss: true, 2008 + serverResponse: this.response, 2009 + }); 2010 + } else { 2011 + fe(ne().body, "htmx:historyCacheMissLoadError", i); 2012 + } 2013 + }; 2014 + e.send(); 2015 + } 2016 + function Yt(e) { 2017 + zt(); 2018 + e = e || location.pathname + location.search; 2019 + const t = _t(e); 2020 + if (t) { 2021 + const n = D(t.content); 2022 + const r = jt(); 2023 + const o = xn(r); 2024 + Dn(n.title); 2025 + Ve(r, n, o); 2026 + Gt(o.tasks); 2027 + E().setTimeout(function () { 2028 + window.scrollTo(0, t.scroll); 2029 + }, 0); 2030 + Ut = e; 2031 + de(ne().body, "htmx:historyRestore", { path: e, item: t }); 2032 + } else { 2033 + if (Q.config.refreshOnHistoryMiss) { 2034 + window.location.reload(true); 2035 + } else { 2036 + Zt(e); 2037 + } 2038 + } 2039 + } 2040 + function Wt(e) { 2041 + let t = Se(e, "hx-indicator"); 2042 + if (t == null) { 2043 + t = [e]; 2044 + } 2045 + se(t, function (e) { 2046 + const t = ie(e); 2047 + t.requestCount = (t.requestCount || 0) + 1; 2048 + e.classList.add.call(e.classList, Q.config.requestClass); 2049 + }); 2050 + return t; 2051 + } 2052 + function Qt(e) { 2053 + let t = Se(e, "hx-disabled-elt"); 2054 + if (t == null) { 2055 + t = []; 2056 + } 2057 + se(t, function (e) { 2058 + const t = ie(e); 2059 + t.requestCount = (t.requestCount || 0) + 1; 2060 + e.setAttribute("disabled", ""); 2061 + e.setAttribute("data-disabled-by-htmx", ""); 2062 + }); 2063 + return t; 2064 + } 2065 + function en(e, t) { 2066 + se(e, function (e) { 2067 + const t = ie(e); 2068 + t.requestCount = (t.requestCount || 0) - 1; 2069 + if (t.requestCount === 0) { 2070 + e.classList.remove.call(e.classList, Q.config.requestClass); 2071 + } 2072 + }); 2073 + se(t, function (e) { 2074 + const t = ie(e); 2075 + t.requestCount = (t.requestCount || 0) - 1; 2076 + if (t.requestCount === 0) { 2077 + e.removeAttribute("disabled"); 2078 + e.removeAttribute("data-disabled-by-htmx"); 2079 + } 2080 + }); 2081 + } 2082 + function tn(t, n) { 2083 + for (let e = 0; e < t.length; e++) { 2084 + const r = t[e]; 2085 + if (r.isSameNode(n)) { 2086 + return true; 2087 + } 2088 + } 2089 + return false; 2090 + } 2091 + function nn(e) { 2092 + const t = e; 2093 + if ( 2094 + t.name === "" || 2095 + t.name == null || 2096 + t.disabled || 2097 + g(t, "fieldset[disabled]") 2098 + ) { 2099 + return false; 2100 + } 2101 + if ( 2102 + t.type === "button" || 2103 + t.type === "submit" || 2104 + t.tagName === "image" || 2105 + t.tagName === "reset" || 2106 + t.tagName === "file" 2107 + ) { 2108 + return false; 2109 + } 2110 + if (t.type === "checkbox" || t.type === "radio") { 2111 + return t.checked; 2112 + } 2113 + return true; 2114 + } 2115 + function rn(t, e, n) { 2116 + if (t != null && e != null) { 2117 + if (Array.isArray(e)) { 2118 + e.forEach(function (e) { 2119 + n.append(t, e); 2120 + }); 2121 + } else { 2122 + n.append(t, e); 2123 + } 2124 + } 2125 + } 2126 + function on(t, n, r) { 2127 + if (t != null && n != null) { 2128 + let e = r.getAll(t); 2129 + if (Array.isArray(n)) { 2130 + e = e.filter((e) => n.indexOf(e) < 0); 2131 + } else { 2132 + e = e.filter((e) => e !== n); 2133 + } 2134 + r.delete(t); 2135 + se(e, (e) => r.append(t, e)); 2136 + } 2137 + } 2138 + function sn(t, n, r, o, i) { 2139 + if (o == null || tn(t, o)) { 2140 + return; 2141 + } else { 2142 + t.push(o); 2143 + } 2144 + if (nn(o)) { 2145 + const s = ee(o, "name"); 2146 + let e = o.value; 2147 + if (o instanceof HTMLSelectElement && o.multiple) { 2148 + e = F(o.querySelectorAll("option:checked")).map(function (e) { 2149 + return e.value; 2150 + }); 2151 + } 2152 + if (o instanceof HTMLInputElement && o.files) { 2153 + e = F(o.files); 2154 + } 2155 + rn(s, e, n); 2156 + if (i) { 2157 + ln(o, r); 2158 + } 2159 + } 2160 + if (o instanceof HTMLFormElement) { 2161 + se(o.elements, function (e) { 2162 + if (t.indexOf(e) >= 0) { 2163 + on(e.name, e.value, n); 2164 + } else { 2165 + t.push(e); 2166 + } 2167 + if (i) { 2168 + ln(e, r); 2169 + } 2170 + }); 2171 + new FormData(o).forEach(function (e, t) { 2172 + if (e instanceof File && e.name === "") { 2173 + return; 2174 + } 2175 + rn(t, e, n); 2176 + }); 2177 + } 2178 + } 2179 + function ln(e, t) { 2180 + const n = e; 2181 + if (n.willValidate) { 2182 + de(n, "htmx:validation:validate"); 2183 + if (!n.checkValidity()) { 2184 + t.push({ elt: n, message: n.validationMessage, validity: n.validity }); 2185 + de(n, "htmx:validation:failed", { 2186 + message: n.validationMessage, 2187 + validity: n.validity, 2188 + }); 2189 + } 2190 + } 2191 + } 2192 + function un(n, e) { 2193 + for (const t of e.keys()) { 2194 + n.delete(t); 2195 + } 2196 + e.forEach(function (e, t) { 2197 + n.append(t, e); 2198 + }); 2199 + return n; 2200 + } 2201 + function cn(e, t) { 2202 + const n = []; 2203 + const r = new FormData(); 2204 + const o = new FormData(); 2205 + const i = []; 2206 + const s = ie(e); 2207 + if (s.lastButtonClicked && !le(s.lastButtonClicked)) { 2208 + s.lastButtonClicked = null; 2209 + } 2210 + let l = 2211 + (e instanceof HTMLFormElement && e.noValidate !== true) || 2212 + te(e, "hx-validate") === "true"; 2213 + if (s.lastButtonClicked) { 2214 + l = l && s.lastButtonClicked.formNoValidate !== true; 2215 + } 2216 + if (t !== "get") { 2217 + sn(n, o, i, g(e, "form"), l); 2218 + } 2219 + sn(n, r, i, e, l); 2220 + if ( 2221 + s.lastButtonClicked || 2222 + e.tagName === "BUTTON" || 2223 + (e.tagName === "INPUT" && ee(e, "type") === "submit") 2224 + ) { 2225 + const c = s.lastButtonClicked || e; 2226 + const a = ee(c, "name"); 2227 + rn(a, c.value, o); 2228 + } 2229 + const u = Se(e, "hx-include"); 2230 + se(u, function (e) { 2231 + sn(n, r, i, ce(e), l); 2232 + if (!f(e, "form")) { 2233 + se(d(e).querySelectorAll(it), function (e) { 2234 + sn(n, r, i, e, l); 2235 + }); 2236 + } 2237 + }); 2238 + un(r, o); 2239 + return { errors: i, formData: r, values: An(r) }; 2240 + } 2241 + function an(e, t, n) { 2242 + if (e !== "") { 2243 + e += "&"; 2244 + } 2245 + if (String(n) === "[object Object]") { 2246 + n = JSON.stringify(n); 2247 + } 2248 + const r = encodeURIComponent(n); 2249 + e += encodeURIComponent(t) + "=" + r; 2250 + return e; 2251 + } 2252 + function fn(e) { 2253 + e = Ln(e); 2254 + let n = ""; 2255 + e.forEach(function (e, t) { 2256 + n = an(n, t, e); 2257 + }); 2258 + return n; 2259 + } 2260 + function dn(e, t, n) { 2261 + const r = { 2262 + "HX-Request": "true", 2263 + "HX-Trigger": ee(e, "id"), 2264 + "HX-Trigger-Name": ee(e, "name"), 2265 + "HX-Target": te(t, "id"), 2266 + "HX-Current-URL": ne().location.href, 2267 + }; 2268 + wn(e, "hx-headers", false, r); 2269 + if (n !== undefined) { 2270 + r["HX-Prompt"] = n; 2271 + } 2272 + if (ie(e).boosted) { 2273 + r["HX-Boosted"] = "true"; 2274 + } 2275 + return r; 2276 + } 2277 + function hn(n, e) { 2278 + const t = re(e, "hx-params"); 2279 + if (t) { 2280 + if (t === "none") { 2281 + return new FormData(); 2282 + } else if (t === "*") { 2283 + return n; 2284 + } else if (t.indexOf("not ") === 0) { 2285 + se(t.substr(4).split(","), function (e) { 2286 + e = e.trim(); 2287 + n.delete(e); 2288 + }); 2289 + return n; 2290 + } else { 2291 + const r = new FormData(); 2292 + se(t.split(","), function (t) { 2293 + t = t.trim(); 2294 + if (n.has(t)) { 2295 + n.getAll(t).forEach(function (e) { 2296 + r.append(t, e); 2297 + }); 2298 + } 2299 + }); 2300 + return r; 2301 + } 2302 + } else { 2303 + return n; 2304 + } 2305 + } 2306 + function gn(e) { 2307 + return !!ee(e, "href") && ee(e, "href").indexOf("#") >= 0; 2308 + } 2309 + function pn(e, t) { 2310 + const n = t || re(e, "hx-swap"); 2311 + const r = { 2312 + swapStyle: ie(e).boosted ? "innerHTML" : Q.config.defaultSwapStyle, 2313 + swapDelay: Q.config.defaultSwapDelay, 2314 + settleDelay: Q.config.defaultSettleDelay, 2315 + }; 2316 + if (Q.config.scrollIntoViewOnBoost && ie(e).boosted && !gn(e)) { 2317 + r.show = "top"; 2318 + } 2319 + if (n) { 2320 + const s = U(n); 2321 + if (s.length > 0) { 2322 + for (let e = 0; e < s.length; e++) { 2323 + const l = s[e]; 2324 + if (l.indexOf("swap:") === 0) { 2325 + r.swapDelay = h(l.substr(5)); 2326 + } else if (l.indexOf("settle:") === 0) { 2327 + r.settleDelay = h(l.substr(7)); 2328 + } else if (l.indexOf("transition:") === 0) { 2329 + r.transition = l.substr(11) === "true"; 2330 + } else if (l.indexOf("ignoreTitle:") === 0) { 2331 + r.ignoreTitle = l.substr(12) === "true"; 2332 + } else if (l.indexOf("scroll:") === 0) { 2333 + const u = l.substr(7); 2334 + var o = u.split(":"); 2335 + const c = o.pop(); 2336 + var i = o.length > 0 ? o.join(":") : null; 2337 + r.scroll = c; 2338 + r.scrollTarget = i; 2339 + } else if (l.indexOf("show:") === 0) { 2340 + const a = l.substr(5); 2341 + var o = a.split(":"); 2342 + const f = o.pop(); 2343 + var i = o.length > 0 ? o.join(":") : null; 2344 + r.show = f; 2345 + r.showTarget = i; 2346 + } else if (l.indexOf("focus-scroll:") === 0) { 2347 + const d = l.substr("focus-scroll:".length); 2348 + r.focusScroll = d == "true"; 2349 + } else if (e == 0) { 2350 + r.swapStyle = l; 2351 + } else { 2352 + w("Unknown modifier in hx-swap: " + l); 2353 + } 2354 + } 2355 + } 2356 + } 2357 + return r; 2358 + } 2359 + function mn(e) { 2360 + return ( 2361 + re(e, "hx-encoding") === "multipart/form-data" || 2362 + (f(e, "form") && ee(e, "enctype") === "multipart/form-data") 2363 + ); 2364 + } 2365 + function yn(t, n, r) { 2366 + let o = null; 2367 + Bt(n, function (e) { 2368 + if (o == null) { 2369 + o = e.encodeParameters(t, r, n); 2370 + } 2371 + }); 2372 + if (o != null) { 2373 + return o; 2374 + } else { 2375 + if (mn(n)) { 2376 + return un(new FormData(), Ln(r)); 2377 + } else { 2378 + return fn(r); 2379 + } 2380 + } 2381 + } 2382 + function xn(e) { 2383 + return { tasks: [], elts: [e] }; 2384 + } 2385 + function bn(e, t) { 2386 + const n = e[0]; 2387 + const r = e[e.length - 1]; 2388 + if (t.scroll) { 2389 + var o = null; 2390 + if (t.scrollTarget) { 2391 + o = ce(ae(n, t.scrollTarget)); 2392 + } 2393 + if (t.scroll === "top" && (n || o)) { 2394 + o = o || n; 2395 + o.scrollTop = 0; 2396 + } 2397 + if (t.scroll === "bottom" && (r || o)) { 2398 + o = o || r; 2399 + o.scrollTop = o.scrollHeight; 2400 + } 2401 + } 2402 + if (t.show) { 2403 + var o = null; 2404 + if (t.showTarget) { 2405 + let e = t.showTarget; 2406 + if (t.showTarget === "window") { 2407 + e = "body"; 2408 + } 2409 + o = ce(ae(n, e)); 2410 + } 2411 + if (t.show === "top" && (n || o)) { 2412 + o = o || n; 2413 + o.scrollIntoView({ block: "start", behavior: Q.config.scrollBehavior }); 2414 + } 2415 + if (t.show === "bottom" && (r || o)) { 2416 + o = o || r; 2417 + o.scrollIntoView({ block: "end", behavior: Q.config.scrollBehavior }); 2418 + } 2419 + } 2420 + } 2421 + function wn(r, e, o, i) { 2422 + if (i == null) { 2423 + i = {}; 2424 + } 2425 + if (r == null) { 2426 + return i; 2427 + } 2428 + const s = te(r, e); 2429 + if (s) { 2430 + let e = s.trim(); 2431 + let t = o; 2432 + if (e === "unset") { 2433 + return null; 2434 + } 2435 + if (e.indexOf("javascript:") === 0) { 2436 + e = e.substr(11); 2437 + t = true; 2438 + } else if (e.indexOf("js:") === 0) { 2439 + e = e.substr(3); 2440 + t = true; 2441 + } 2442 + if (e.indexOf("{") !== 0) { 2443 + e = "{" + e + "}"; 2444 + } 2445 + let n; 2446 + if (t) { 2447 + n = vn( 2448 + r, 2449 + function () { 2450 + return Function("return (" + e + ")")(); 2451 + }, 2452 + {}, 2453 + ); 2454 + } else { 2455 + n = S(e); 2456 + } 2457 + for (const l in n) { 2458 + if (n.hasOwnProperty(l)) { 2459 + if (i[l] == null) { 2460 + i[l] = n[l]; 2461 + } 2462 + } 2463 + } 2464 + } 2465 + return wn(ce(u(r)), e, o, i); 2466 + } 2467 + function vn(e, t, n) { 2468 + if (Q.config.allowEval) { 2469 + return t(); 2470 + } else { 2471 + fe(e, "htmx:evalDisallowedError"); 2472 + return n; 2473 + } 2474 + } 2475 + function Sn(e, t) { 2476 + return wn(e, "hx-vars", true, t); 2477 + } 2478 + function En(e, t) { 2479 + return wn(e, "hx-vals", false, t); 2480 + } 2481 + function Cn(e) { 2482 + return ue(Sn(e), En(e)); 2483 + } 2484 + function On(t, n, r) { 2485 + if (r !== null) { 2486 + try { 2487 + t.setRequestHeader(n, r); 2488 + } catch (e) { 2489 + t.setRequestHeader(n, encodeURIComponent(r)); 2490 + t.setRequestHeader(n + "-URI-AutoEncoded", "true"); 2491 + } 2492 + } 2493 + } 2494 + function Rn(t) { 2495 + if (t.responseURL && typeof URL !== "undefined") { 2496 + try { 2497 + const e = new URL(t.responseURL); 2498 + return e.pathname + e.search; 2499 + } catch (e) { 2500 + fe(ne().body, "htmx:badResponseUrl", { url: t.responseURL }); 2501 + } 2502 + } 2503 + } 2504 + function C(e, t) { 2505 + return t.test(e.getAllResponseHeaders()); 2506 + } 2507 + function Hn(e, t, n) { 2508 + e = e.toLowerCase(); 2509 + if (n) { 2510 + if (n instanceof Element || typeof n === "string") { 2511 + return he(e, t, null, null, { 2512 + targetOverride: y(n), 2513 + returnPromise: true, 2514 + }); 2515 + } else { 2516 + return he(e, t, y(n.source), n.event, { 2517 + handler: n.handler, 2518 + headers: n.headers, 2519 + values: n.values, 2520 + targetOverride: y(n.target), 2521 + swapOverride: n.swap, 2522 + select: n.select, 2523 + returnPromise: true, 2524 + }); 2525 + } 2526 + } else { 2527 + return he(e, t, null, null, { returnPromise: true }); 2528 + } 2529 + } 2530 + function Tn(e) { 2531 + const t = []; 2532 + while (e) { 2533 + t.push(e); 2534 + e = e.parentElement; 2535 + } 2536 + return t; 2537 + } 2538 + function qn(e, t, n) { 2539 + let r; 2540 + let o; 2541 + if (typeof URL === "function") { 2542 + o = new URL(t, document.location.href); 2543 + const i = document.location.origin; 2544 + r = i === o.origin; 2545 + } else { 2546 + o = t; 2547 + r = l(t, document.location.origin); 2548 + } 2549 + if (Q.config.selfRequestsOnly) { 2550 + if (!r) { 2551 + return false; 2552 + } 2553 + } 2554 + return de(e, "htmx:validateUrl", ue({ url: o, sameHost: r }, n)); 2555 + } 2556 + function Ln(e) { 2557 + if (e instanceof FormData) return e; 2558 + const t = new FormData(); 2559 + for (const n in e) { 2560 + if (e.hasOwnProperty(n)) { 2561 + if (typeof e[n].forEach === "function") { 2562 + e[n].forEach(function (e) { 2563 + t.append(n, e); 2564 + }); 2565 + } else if (typeof e[n] === "object" && !(e[n] instanceof Blob)) { 2566 + t.append(n, JSON.stringify(e[n])); 2567 + } else { 2568 + t.append(n, e[n]); 2569 + } 2570 + } 2571 + } 2572 + return t; 2573 + } 2574 + function Nn(r, o, e) { 2575 + return new Proxy(e, { 2576 + get: function (t, e) { 2577 + if (typeof e === "number") return t[e]; 2578 + if (e === "length") return t.length; 2579 + if (e === "push") { 2580 + return function (e) { 2581 + t.push(e); 2582 + r.append(o, e); 2583 + }; 2584 + } 2585 + if (typeof t[e] === "function") { 2586 + return function () { 2587 + t[e].apply(t, arguments); 2588 + r.delete(o); 2589 + t.forEach(function (e) { 2590 + r.append(o, e); 2591 + }); 2592 + }; 2593 + } 2594 + if (t[e] && t[e].length === 1) { 2595 + return t[e][0]; 2596 + } else { 2597 + return t[e]; 2598 + } 2599 + }, 2600 + set: function (e, t, n) { 2601 + e[t] = n; 2602 + r.delete(o); 2603 + e.forEach(function (e) { 2604 + r.append(o, e); 2605 + }); 2606 + return true; 2607 + }, 2608 + }); 2609 + } 2610 + function An(r) { 2611 + return new Proxy(r, { 2612 + get: function (e, t) { 2613 + if (typeof t === "symbol") { 2614 + return Reflect.get(e, t); 2615 + } 2616 + if (t === "toJSON") { 2617 + return () => Object.fromEntries(r); 2618 + } 2619 + if (t in e) { 2620 + if (typeof e[t] === "function") { 2621 + return function () { 2622 + return r[t].apply(r, arguments); 2623 + }; 2624 + } else { 2625 + return e[t]; 2626 + } 2627 + } 2628 + const n = r.getAll(t); 2629 + if (n.length === 0) { 2630 + return undefined; 2631 + } else if (n.length === 1) { 2632 + return n[0]; 2633 + } else { 2634 + return Nn(e, t, n); 2635 + } 2636 + }, 2637 + set: function (t, n, e) { 2638 + if (typeof n !== "string") { 2639 + return false; 2640 + } 2641 + t.delete(n); 2642 + if (typeof e.forEach === "function") { 2643 + e.forEach(function (e) { 2644 + t.append(n, e); 2645 + }); 2646 + } else if (typeof e === "object" && !(e instanceof Blob)) { 2647 + t.append(n, JSON.stringify(e)); 2648 + } else { 2649 + t.append(n, e); 2650 + } 2651 + return true; 2652 + }, 2653 + deleteProperty: function (e, t) { 2654 + if (typeof t === "string") { 2655 + e.delete(t); 2656 + } 2657 + return true; 2658 + }, 2659 + ownKeys: function (e) { 2660 + return Reflect.ownKeys(Object.fromEntries(e)); 2661 + }, 2662 + getOwnPropertyDescriptor: function (e, t) { 2663 + return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e), t); 2664 + }, 2665 + }); 2666 + } 2667 + function he(t, n, r, o, i, D) { 2668 + let s = null; 2669 + let l = null; 2670 + i = i != null ? i : {}; 2671 + if (i.returnPromise && typeof Promise !== "undefined") { 2672 + var e = new Promise(function (e, t) { 2673 + s = e; 2674 + l = t; 2675 + }); 2676 + } 2677 + if (r == null) { 2678 + r = ne().body; 2679 + } 2680 + const M = i.handler || Mn; 2681 + const X = i.select || null; 2682 + if (!le(r)) { 2683 + oe(s); 2684 + return e; 2685 + } 2686 + const u = i.targetOverride || ce(Ce(r)); 2687 + if (u == null || u == ve) { 2688 + fe(r, "htmx:targetError", { target: te(r, "hx-target") }); 2689 + oe(l); 2690 + return e; 2691 + } 2692 + let c = ie(r); 2693 + const a = c.lastButtonClicked; 2694 + if (a) { 2695 + const L = ee(a, "formaction"); 2696 + if (L != null) { 2697 + n = L; 2698 + } 2699 + const N = ee(a, "formmethod"); 2700 + if (N != null) { 2701 + if (N.toLowerCase() !== "dialog") { 2702 + t = N; 2703 + } 2704 + } 2705 + } 2706 + const f = re(r, "hx-confirm"); 2707 + if (D === undefined) { 2708 + const K = function (e) { 2709 + return he(t, n, r, o, i, !!e); 2710 + }; 2711 + const G = { 2712 + target: u, 2713 + elt: r, 2714 + path: n, 2715 + verb: t, 2716 + triggeringEvent: o, 2717 + etc: i, 2718 + issueRequest: K, 2719 + question: f, 2720 + }; 2721 + if (de(r, "htmx:confirm", G) === false) { 2722 + oe(s); 2723 + return e; 2724 + } 2725 + } 2726 + let d = r; 2727 + let h = re(r, "hx-sync"); 2728 + let g = null; 2729 + let F = false; 2730 + if (h) { 2731 + const A = h.split(":"); 2732 + const I = A[0].trim(); 2733 + if (I === "this") { 2734 + d = Ee(r, "hx-sync"); 2735 + } else { 2736 + d = ce(ae(r, I)); 2737 + } 2738 + h = (A[1] || "drop").trim(); 2739 + c = ie(d); 2740 + if (h === "drop" && c.xhr && c.abortable !== true) { 2741 + oe(s); 2742 + return e; 2743 + } else if (h === "abort") { 2744 + if (c.xhr) { 2745 + oe(s); 2746 + return e; 2747 + } else { 2748 + F = true; 2749 + } 2750 + } else if (h === "replace") { 2751 + de(d, "htmx:abort"); 2752 + } else if (h.indexOf("queue") === 0) { 2753 + const Z = h.split(" "); 2754 + g = (Z[1] || "last").trim(); 2755 + } 2756 + } 2757 + if (c.xhr) { 2758 + if (c.abortable) { 2759 + de(d, "htmx:abort"); 2760 + } else { 2761 + if (g == null) { 2762 + if (o) { 2763 + const P = ie(o); 2764 + if (P && P.triggerSpec && P.triggerSpec.queue) { 2765 + g = P.triggerSpec.queue; 2766 + } 2767 + } 2768 + if (g == null) { 2769 + g = "last"; 2770 + } 2771 + } 2772 + if (c.queuedRequests == null) { 2773 + c.queuedRequests = []; 2774 + } 2775 + if (g === "first" && c.queuedRequests.length === 0) { 2776 + c.queuedRequests.push(function () { 2777 + he(t, n, r, o, i); 2778 + }); 2779 + } else if (g === "all") { 2780 + c.queuedRequests.push(function () { 2781 + he(t, n, r, o, i); 2782 + }); 2783 + } else if (g === "last") { 2784 + c.queuedRequests = []; 2785 + c.queuedRequests.push(function () { 2786 + he(t, n, r, o, i); 2787 + }); 2788 + } 2789 + oe(s); 2790 + return e; 2791 + } 2792 + } 2793 + const p = new XMLHttpRequest(); 2794 + c.xhr = p; 2795 + c.abortable = F; 2796 + const m = function () { 2797 + c.xhr = null; 2798 + c.abortable = false; 2799 + if (c.queuedRequests != null && c.queuedRequests.length > 0) { 2800 + const e = c.queuedRequests.shift(); 2801 + e(); 2802 + } 2803 + }; 2804 + const B = re(r, "hx-prompt"); 2805 + if (B) { 2806 + var y = prompt(B); 2807 + if (y === null || !de(r, "htmx:prompt", { prompt: y, target: u })) { 2808 + oe(s); 2809 + m(); 2810 + return e; 2811 + } 2812 + } 2813 + if (f && !D) { 2814 + if (!confirm(f)) { 2815 + oe(s); 2816 + m(); 2817 + return e; 2818 + } 2819 + } 2820 + let x = dn(r, u, y); 2821 + if (t !== "get" && !mn(r)) { 2822 + x["Content-Type"] = "application/x-www-form-urlencoded"; 2823 + } 2824 + if (i.headers) { 2825 + x = ue(x, i.headers); 2826 + } 2827 + const U = cn(r, t); 2828 + let b = U.errors; 2829 + const j = U.formData; 2830 + if (i.values) { 2831 + un(j, Ln(i.values)); 2832 + } 2833 + const V = Ln(Cn(r)); 2834 + const w = un(j, V); 2835 + let v = hn(w, r); 2836 + if (Q.config.getCacheBusterParam && t === "get") { 2837 + v.set("org.htmx.cache-buster", ee(u, "id") || "true"); 2838 + } 2839 + if (n == null || n === "") { 2840 + n = ne().location.href; 2841 + } 2842 + const S = wn(r, "hx-request"); 2843 + const _ = ie(r).boosted; 2844 + let E = Q.config.methodsThatUseUrlParams.indexOf(t) >= 0; 2845 + const C = { 2846 + boosted: _, 2847 + useUrlParams: E, 2848 + formData: v, 2849 + parameters: An(v), 2850 + unfilteredFormData: w, 2851 + unfilteredParameters: An(w), 2852 + headers: x, 2853 + target: u, 2854 + verb: t, 2855 + errors: b, 2856 + withCredentials: 2857 + i.credentials || S.credentials || Q.config.withCredentials, 2858 + timeout: i.timeout || S.timeout || Q.config.timeout, 2859 + path: n, 2860 + triggeringEvent: o, 2861 + }; 2862 + if (!de(r, "htmx:configRequest", C)) { 2863 + oe(s); 2864 + m(); 2865 + return e; 2866 + } 2867 + n = C.path; 2868 + t = C.verb; 2869 + x = C.headers; 2870 + v = Ln(C.parameters); 2871 + b = C.errors; 2872 + E = C.useUrlParams; 2873 + if (b && b.length > 0) { 2874 + de(r, "htmx:validation:halted", C); 2875 + oe(s); 2876 + m(); 2877 + return e; 2878 + } 2879 + const $ = n.split("#"); 2880 + const z = $[0]; 2881 + const O = $[1]; 2882 + let R = n; 2883 + if (E) { 2884 + R = z; 2885 + const Y = !v.keys().next().done; 2886 + if (Y) { 2887 + if (R.indexOf("?") < 0) { 2888 + R += "?"; 2889 + } else { 2890 + R += "&"; 2891 + } 2892 + R += fn(v); 2893 + if (O) { 2894 + R += "#" + O; 2895 + } 2896 + } 2897 + } 2898 + if (!qn(r, R, C)) { 2899 + fe(r, "htmx:invalidPath", C); 2900 + oe(l); 2901 + return e; 2902 + } 2903 + p.open(t.toUpperCase(), R, true); 2904 + p.overrideMimeType("text/html"); 2905 + p.withCredentials = C.withCredentials; 2906 + p.timeout = C.timeout; 2907 + if (S.noHeaders) { 2908 + } else { 2909 + for (const k in x) { 2910 + if (x.hasOwnProperty(k)) { 2911 + const W = x[k]; 2912 + On(p, k, W); 2913 + } 2914 + } 2915 + } 2916 + const H = { 2917 + xhr: p, 2918 + target: u, 2919 + requestConfig: C, 2920 + etc: i, 2921 + boosted: _, 2922 + select: X, 2923 + pathInfo: { 2924 + requestPath: n, 2925 + finalRequestPath: R, 2926 + responsePath: null, 2927 + anchor: O, 2928 + }, 2929 + }; 2930 + p.onload = function () { 2931 + try { 2932 + const t = Tn(r); 2933 + H.pathInfo.responsePath = Rn(p); 2934 + M(r, H); 2935 + if (H.keepIndicators !== true) { 2936 + en(T, q); 2937 + } 2938 + de(r, "htmx:afterRequest", H); 2939 + de(r, "htmx:afterOnLoad", H); 2940 + if (!le(r)) { 2941 + let e = null; 2942 + while (t.length > 0 && e == null) { 2943 + const n = t.shift(); 2944 + if (le(n)) { 2945 + e = n; 2946 + } 2947 + } 2948 + if (e) { 2949 + de(e, "htmx:afterRequest", H); 2950 + de(e, "htmx:afterOnLoad", H); 2951 + } 2952 + } 2953 + oe(s); 2954 + m(); 2955 + } catch (e) { 2956 + fe(r, "htmx:onLoadError", ue({ error: e }, H)); 2957 + throw e; 2958 + } 2959 + }; 2960 + p.onerror = function () { 2961 + en(T, q); 2962 + fe(r, "htmx:afterRequest", H); 2963 + fe(r, "htmx:sendError", H); 2964 + oe(l); 2965 + m(); 2966 + }; 2967 + p.onabort = function () { 2968 + en(T, q); 2969 + fe(r, "htmx:afterRequest", H); 2970 + fe(r, "htmx:sendAbort", H); 2971 + oe(l); 2972 + m(); 2973 + }; 2974 + p.ontimeout = function () { 2975 + en(T, q); 2976 + fe(r, "htmx:afterRequest", H); 2977 + fe(r, "htmx:timeout", H); 2978 + oe(l); 2979 + m(); 2980 + }; 2981 + if (!de(r, "htmx:beforeRequest", H)) { 2982 + oe(s); 2983 + m(); 2984 + return e; 2985 + } 2986 + var T = Wt(r); 2987 + var q = Qt(r); 2988 + se(["loadstart", "loadend", "progress", "abort"], function (t) { 2989 + se([p, p.upload], function (e) { 2990 + e.addEventListener(t, function (e) { 2991 + de(r, "htmx:xhr:" + t, { 2992 + lengthComputable: e.lengthComputable, 2993 + loaded: e.loaded, 2994 + total: e.total, 2995 + }); 2996 + }); 2997 + }); 2998 + }); 2999 + de(r, "htmx:beforeSend", H); 3000 + const J = E ? null : yn(p, r, v); 3001 + p.send(J); 3002 + return e; 3003 + } 3004 + function In(e, t) { 3005 + const n = t.xhr; 3006 + let r = null; 3007 + let o = null; 3008 + if (C(n, /HX-Push:/i)) { 3009 + r = n.getResponseHeader("HX-Push"); 3010 + o = "push"; 3011 + } else if (C(n, /HX-Push-Url:/i)) { 3012 + r = n.getResponseHeader("HX-Push-Url"); 3013 + o = "push"; 3014 + } else if (C(n, /HX-Replace-Url:/i)) { 3015 + r = n.getResponseHeader("HX-Replace-Url"); 3016 + o = "replace"; 3017 + } 3018 + if (r) { 3019 + if (r === "false") { 3020 + return {}; 3021 + } else { 3022 + return { type: o, path: r }; 3023 + } 3024 + } 3025 + const i = t.pathInfo.finalRequestPath; 3026 + const s = t.pathInfo.responsePath; 3027 + const l = re(e, "hx-push-url"); 3028 + const u = re(e, "hx-replace-url"); 3029 + const c = ie(e).boosted; 3030 + let a = null; 3031 + let f = null; 3032 + if (l) { 3033 + a = "push"; 3034 + f = l; 3035 + } else if (u) { 3036 + a = "replace"; 3037 + f = u; 3038 + } else if (c) { 3039 + a = "push"; 3040 + f = s || i; 3041 + } 3042 + if (f) { 3043 + if (f === "false") { 3044 + return {}; 3045 + } 3046 + if (f === "true") { 3047 + f = s || i; 3048 + } 3049 + if (t.pathInfo.anchor && f.indexOf("#") === -1) { 3050 + f = f + "#" + t.pathInfo.anchor; 3051 + } 3052 + return { type: a, path: f }; 3053 + } else { 3054 + return {}; 3055 + } 3056 + } 3057 + function Pn(e, t) { 3058 + var n = new RegExp(e.code); 3059 + return n.test(t.toString(10)); 3060 + } 3061 + function kn(e) { 3062 + for (var t = 0; t < Q.config.responseHandling.length; t++) { 3063 + var n = Q.config.responseHandling[t]; 3064 + if (Pn(n, e.status)) { 3065 + return n; 3066 + } 3067 + } 3068 + return { swap: false }; 3069 + } 3070 + function Dn(e) { 3071 + if (e) { 3072 + const t = r("title"); 3073 + if (t) { 3074 + t.innerHTML = e; 3075 + } else { 3076 + window.document.title = e; 3077 + } 3078 + } 3079 + } 3080 + function Mn(o, i) { 3081 + const s = i.xhr; 3082 + let l = i.target; 3083 + const e = i.etc; 3084 + const u = i.select; 3085 + if (!de(o, "htmx:beforeOnLoad", i)) return; 3086 + if (C(s, /HX-Trigger:/i)) { 3087 + Je(s, "HX-Trigger", o); 3088 + } 3089 + if (C(s, /HX-Location:/i)) { 3090 + zt(); 3091 + let e = s.getResponseHeader("HX-Location"); 3092 + var t; 3093 + if (e.indexOf("{") === 0) { 3094 + t = S(e); 3095 + e = t.path; 3096 + delete t.path; 3097 + } 3098 + Hn("get", e, t).then(function () { 3099 + Jt(e); 3100 + }); 3101 + return; 3102 + } 3103 + const n = 3104 + C(s, /HX-Refresh:/i) && s.getResponseHeader("HX-Refresh") === "true"; 3105 + if (C(s, /HX-Redirect:/i)) { 3106 + i.keepIndicators = true; 3107 + location.href = s.getResponseHeader("HX-Redirect"); 3108 + n && location.reload(); 3109 + return; 3110 + } 3111 + if (n) { 3112 + i.keepIndicators = true; 3113 + location.reload(); 3114 + return; 3115 + } 3116 + if (C(s, /HX-Retarget:/i)) { 3117 + if (s.getResponseHeader("HX-Retarget") === "this") { 3118 + i.target = o; 3119 + } else { 3120 + i.target = ce(ae(o, s.getResponseHeader("HX-Retarget"))); 3121 + } 3122 + } 3123 + const c = In(o, i); 3124 + const r = kn(s); 3125 + const a = r.swap; 3126 + let f = !!r.error; 3127 + let d = Q.config.ignoreTitle || r.ignoreTitle; 3128 + let h = r.select; 3129 + if (r.target) { 3130 + i.target = ce(ae(o, r.target)); 3131 + } 3132 + var g = e.swapOverride; 3133 + if (g == null && r.swapOverride) { 3134 + g = r.swapOverride; 3135 + } 3136 + if (C(s, /HX-Retarget:/i)) { 3137 + if (s.getResponseHeader("HX-Retarget") === "this") { 3138 + i.target = o; 3139 + } else { 3140 + i.target = ce(ae(o, s.getResponseHeader("HX-Retarget"))); 3141 + } 3142 + } 3143 + if (C(s, /HX-Reswap:/i)) { 3144 + g = s.getResponseHeader("HX-Reswap"); 3145 + } 3146 + var p = s.response; 3147 + var m = ue( 3148 + { 3149 + shouldSwap: a, 3150 + serverResponse: p, 3151 + isError: f, 3152 + ignoreTitle: d, 3153 + selectOverride: h, 3154 + }, 3155 + i, 3156 + ); 3157 + if (r.event && !de(l, r.event, m)) return; 3158 + if (!de(l, "htmx:beforeSwap", m)) return; 3159 + l = m.target; 3160 + p = m.serverResponse; 3161 + f = m.isError; 3162 + d = m.ignoreTitle; 3163 + h = m.selectOverride; 3164 + i.target = l; 3165 + i.failed = f; 3166 + i.successful = !f; 3167 + if (m.shouldSwap) { 3168 + if (s.status === 286) { 3169 + ut(o); 3170 + } 3171 + Bt(o, function (e) { 3172 + p = e.transformResponse(p, s, o); 3173 + }); 3174 + if (c.type) { 3175 + zt(); 3176 + } 3177 + if (C(s, /HX-Reswap:/i)) { 3178 + g = s.getResponseHeader("HX-Reswap"); 3179 + } 3180 + var y = pn(o, g); 3181 + if (!y.hasOwnProperty("ignoreTitle")) { 3182 + y.ignoreTitle = d; 3183 + } 3184 + l.classList.add(Q.config.swappingClass); 3185 + let n = null; 3186 + let r = null; 3187 + if (u) { 3188 + h = u; 3189 + } 3190 + if (C(s, /HX-Reselect:/i)) { 3191 + h = s.getResponseHeader("HX-Reselect"); 3192 + } 3193 + const x = re(o, "hx-select-oob"); 3194 + const b = re(o, "hx-select"); 3195 + let e = function () { 3196 + try { 3197 + if (c.type) { 3198 + de(ne().body, "htmx:beforeHistoryUpdate", ue({ history: c }, i)); 3199 + if (c.type === "push") { 3200 + Jt(c.path); 3201 + de(ne().body, "htmx:pushedIntoHistory", { path: c.path }); 3202 + } else { 3203 + Kt(c.path); 3204 + de(ne().body, "htmx:replacedInHistory", { path: c.path }); 3205 + } 3206 + } 3207 + ze(l, p, y, { 3208 + select: h || b, 3209 + selectOOB: x, 3210 + eventInfo: i, 3211 + anchor: i.pathInfo.anchor, 3212 + contextElement: o, 3213 + afterSwapCallback: function () { 3214 + if (C(s, /HX-Trigger-After-Swap:/i)) { 3215 + let e = o; 3216 + if (!le(o)) { 3217 + e = ne().body; 3218 + } 3219 + Je(s, "HX-Trigger-After-Swap", e); 3220 + } 3221 + }, 3222 + afterSettleCallback: function () { 3223 + if (C(s, /HX-Trigger-After-Settle:/i)) { 3224 + let e = o; 3225 + if (!le(o)) { 3226 + e = ne().body; 3227 + } 3228 + Je(s, "HX-Trigger-After-Settle", e); 3229 + } 3230 + oe(n); 3231 + }, 3232 + }); 3233 + } catch (e) { 3234 + fe(o, "htmx:swapError", i); 3235 + oe(r); 3236 + throw e; 3237 + } 3238 + }; 3239 + let t = Q.config.globalViewTransitions; 3240 + if (y.hasOwnProperty("transition")) { 3241 + t = y.transition; 3242 + } 3243 + if ( 3244 + t && 3245 + de(o, "htmx:beforeTransition", i) && 3246 + typeof Promise !== "undefined" && 3247 + document.startViewTransition 3248 + ) { 3249 + const w = new Promise(function (e, t) { 3250 + n = e; 3251 + r = t; 3252 + }); 3253 + const v = e; 3254 + e = function () { 3255 + document.startViewTransition(function () { 3256 + v(); 3257 + return w; 3258 + }); 3259 + }; 3260 + } 3261 + if (y.swapDelay > 0) { 3262 + E().setTimeout(e, y.swapDelay); 3263 + } else { 3264 + e(); 3265 + } 3266 + } 3267 + if (f) { 3268 + fe( 3269 + o, 3270 + "htmx:responseError", 3271 + ue( 3272 + { 3273 + error: 3274 + "Response Status Error Code " + 3275 + s.status + 3276 + " from " + 3277 + i.pathInfo.requestPath, 3278 + }, 3279 + i, 3280 + ), 3281 + ); 3282 + } 3283 + } 3284 + const Xn = {}; 3285 + function Fn() { 3286 + return { 3287 + init: function (e) { 3288 + return null; 3289 + }, 3290 + getSelectors: function () { 3291 + return null; 3292 + }, 3293 + onEvent: function (e, t) { 3294 + return true; 3295 + }, 3296 + transformResponse: function (e, t, n) { 3297 + return e; 3298 + }, 3299 + isInlineSwap: function (e) { 3300 + return false; 3301 + }, 3302 + handleSwap: function (e, t, n, r) { 3303 + return false; 3304 + }, 3305 + encodeParameters: function (e, t, n) { 3306 + return null; 3307 + }, 3308 + }; 3309 + } 3310 + function Bn(e, t) { 3311 + if (t.init) { 3312 + t.init(n); 3313 + } 3314 + Xn[e] = ue(Fn(), t); 3315 + } 3316 + function Un(e) { 3317 + delete Xn[e]; 3318 + } 3319 + function jn(e, n, r) { 3320 + if (n == undefined) { 3321 + n = []; 3322 + } 3323 + if (e == undefined) { 3324 + return n; 3325 + } 3326 + if (r == undefined) { 3327 + r = []; 3328 + } 3329 + const t = te(e, "hx-ext"); 3330 + if (t) { 3331 + se(t.split(","), function (e) { 3332 + e = e.replace(/ /g, ""); 3333 + if (e.slice(0, 7) == "ignore:") { 3334 + r.push(e.slice(7)); 3335 + return; 3336 + } 3337 + if (r.indexOf(e) < 0) { 3338 + const t = Xn[e]; 3339 + if (t && n.indexOf(t) < 0) { 3340 + n.push(t); 3341 + } 3342 + } 3343 + }); 3344 + } 3345 + return jn(ce(u(e)), n, r); 3346 + } 3347 + var Vn = false; 3348 + ne().addEventListener("DOMContentLoaded", function () { 3349 + Vn = true; 3350 + }); 3351 + function _n(e) { 3352 + if (Vn || ne().readyState === "complete") { 3353 + e(); 3354 + } else { 3355 + ne().addEventListener("DOMContentLoaded", e); 3356 + } 3357 + } 3358 + function $n() { 3359 + if (Q.config.includeIndicatorStyles !== false) { 3360 + const e = Q.config.inlineStyleNonce 3361 + ? ` nonce="${Q.config.inlineStyleNonce}"` 3362 + : ""; 3363 + ne().head.insertAdjacentHTML( 3364 + "beforeend", 3365 + "<style" + 3366 + e + 3367 + "> ." + 3368 + Q.config.indicatorClass + 3369 + "{opacity:0} ." + 3370 + Q.config.requestClass + 3371 + " ." + 3372 + Q.config.indicatorClass + 3373 + "{opacity:1; transition: opacity 200ms ease-in;} ." + 3374 + Q.config.requestClass + 3375 + "." + 3376 + Q.config.indicatorClass + 3377 + "{opacity:1; transition: opacity 200ms ease-in;} </style>", 3378 + ); 3379 + } 3380 + } 3381 + function zn() { 3382 + const e = ne().querySelector('meta[name="htmx-config"]'); 3383 + if (e) { 3384 + return S(e.content); 3385 + } else { 3386 + return null; 3387 + } 3388 + } 3389 + function Jn() { 3390 + const e = zn(); 3391 + if (e) { 3392 + Q.config = ue(Q.config, e); 3393 + } 3394 + } 3395 + _n(function () { 3396 + Jn(); 3397 + $n(); 3398 + let e = ne().body; 3399 + Dt(e); 3400 + const t = ne().querySelectorAll( 3401 + "[hx-trigger='restored'],[data-hx-trigger='restored']", 3402 + ); 3403 + e.addEventListener("htmx:abort", function (e) { 3404 + const t = e.target; 3405 + const n = ie(t); 3406 + if (n && n.xhr) { 3407 + n.xhr.abort(); 3408 + } 3409 + }); 3410 + const n = window.onpopstate ? window.onpopstate.bind(window) : null; 3411 + window.onpopstate = function (e) { 3412 + if (e.state && e.state.htmx) { 3413 + Yt(); 3414 + se(t, function (e) { 3415 + de(e, "htmx:restored", { document: ne(), triggerEvent: de }); 3416 + }); 3417 + } else { 3418 + if (n) { 3419 + n(e); 3420 + } 3421 + } 3422 + }; 3423 + E().setTimeout(function () { 3424 + de(e, "htmx:load", {}); 3425 + e = null; 3426 + }, 0); 3427 + }); 3428 + return Q; 3429 + })();
+18
web/htmx/htmx.templ
··· 1 + package htmx 2 + 3 + // Use pulls in the HTMX core library and any extensions. 4 + // 5 + // Right now the extensions you can choose from are: 6 + // 7 + // * event-header 8 + // * path-params 9 + // * remove-me 10 + // * websocket 11 + // 12 + // This is mostly based on the extensions that I personally use. 13 + templ Use(exts ...string) { 14 + <script src={ URL + "htmx.js" }></script> 15 + for _, ext := range exts { 16 + <script src={ URL + ext + ".js" }></script> 17 + } 18 + }
+82
web/htmx/htmx_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.3.865 4 + package htmx 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + // Use pulls in the HTMX core library and any extensions. 12 + // 13 + // Right now the extensions you can choose from are: 14 + // 15 + // - event-header 16 + // - path-params 17 + // - remove-me 18 + // - websocket 19 + // 20 + // This is mostly based on the extensions that I personally use. 21 + func Use(exts ...string) templ.Component { 22 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 23 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 24 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 25 + return templ_7745c5c3_CtxErr 26 + } 27 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 28 + if !templ_7745c5c3_IsBuffer { 29 + defer func() { 30 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 31 + if templ_7745c5c3_Err == nil { 32 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 33 + } 34 + }() 35 + } 36 + ctx = templ.InitializeContext(ctx) 37 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 38 + if templ_7745c5c3_Var1 == nil { 39 + templ_7745c5c3_Var1 = templ.NopComponent 40 + } 41 + ctx = templ.ClearChildren(ctx) 42 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script src=\"") 43 + if templ_7745c5c3_Err != nil { 44 + return templ_7745c5c3_Err 45 + } 46 + var templ_7745c5c3_Var2 string 47 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(URL + "htmx.js") 48 + if templ_7745c5c3_Err != nil { 49 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `htmx.templ`, Line: 14, Col: 30} 50 + } 51 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 52 + if templ_7745c5c3_Err != nil { 53 + return templ_7745c5c3_Err 54 + } 55 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></script>") 56 + if templ_7745c5c3_Err != nil { 57 + return templ_7745c5c3_Err 58 + } 59 + for _, ext := range exts { 60 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<script src=\"") 61 + if templ_7745c5c3_Err != nil { 62 + return templ_7745c5c3_Err 63 + } 64 + var templ_7745c5c3_Var3 string 65 + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(URL + ext + ".js") 66 + if templ_7745c5c3_Err != nil { 67 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `htmx.templ`, Line: 16, Col: 33} 68 + } 69 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 70 + if templ_7745c5c3_Err != nil { 71 + return templ_7745c5c3_Err 72 + } 73 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></script>") 74 + if templ_7745c5c3_Err != nil { 75 + return templ_7745c5c3_Err 76 + } 77 + } 78 + return nil 79 + }) 80 + } 81 + 82 + var _ = templruntime.GeneratedTemplate
+16
web/htmx/path-params.js
··· 1 + htmx.defineExtension("path-params", { 2 + onEvent: function (name, evt) { 3 + if (name === "htmx:configRequest") { 4 + evt.detail.path = evt.detail.path.replace( 5 + /{([^}]+)}/g, 6 + function (_, param) { 7 + var val = evt.detail.parameters[param]; 8 + delete evt.detail.parameters[param]; 9 + return val === undefined 10 + ? "{" + param + "}" 11 + : encodeURIComponent(val); 12 + }, 13 + ); 14 + } 15 + }, 16 + });
+30
web/htmx/remove-me.js
··· 1 + (function () { 2 + function maybeRemoveMe(elt) { 3 + var timing = 4 + elt.getAttribute("remove-me") || elt.getAttribute("data-remove-me"); 5 + if (timing) { 6 + setTimeout(function () { 7 + elt.parentElement.removeChild(elt); 8 + }, htmx.parseInterval(timing)); 9 + } 10 + } 11 + 12 + htmx.defineExtension("remove-me", { 13 + onEvent: function (name, evt) { 14 + if (name === "htmx:afterProcessNode") { 15 + var elt = evt.detail.elt; 16 + if (elt.getAttribute) { 17 + maybeRemoveMe(elt); 18 + if (elt.querySelectorAll) { 19 + var children = elt.querySelectorAll( 20 + "[remove-me], [data-remove-me]", 21 + ); 22 + for (var i = 0; i < children.length; i++) { 23 + maybeRemoveMe(children[i]); 24 + } 25 + } 26 + } 27 + } 28 + }, 29 + }); 30 + })();
+513
web/htmx/websocket.js
··· 1 + /* 2 + WebSockets Extension 3 + ============================ 4 + This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions. 5 + */ 6 + 7 + (function () { 8 + /** @type {import("../htmx").HtmxInternalApi} */ 9 + var api; 10 + 11 + htmx.defineExtension("ws", { 12 + /** 13 + * init is called once, when this extension is first registered. 14 + * @param {import("../htmx").HtmxInternalApi} apiRef 15 + */ 16 + init: function (apiRef) { 17 + // Store reference to internal API 18 + api = apiRef; 19 + 20 + // Default function for creating new EventSource objects 21 + if (!htmx.createWebSocket) { 22 + htmx.createWebSocket = createWebSocket; 23 + } 24 + 25 + // Default setting for reconnect delay 26 + if (!htmx.config.wsReconnectDelay) { 27 + htmx.config.wsReconnectDelay = "full-jitter"; 28 + } 29 + }, 30 + 31 + /** 32 + * onEvent handles all events passed to this extension. 33 + * 34 + * @param {string} name 35 + * @param {Event} evt 36 + */ 37 + onEvent: function (name, evt) { 38 + var parent = evt.target || evt.detail.elt; 39 + switch (name) { 40 + // Try to close the socket when elements are removed 41 + case "htmx:beforeCleanupElement": 42 + var internalData = api.getInternalData(parent); 43 + 44 + if (internalData.webSocket) { 45 + internalData.webSocket.close(); 46 + } 47 + return; 48 + 49 + // Try to create websockets when elements are processed 50 + case "htmx:beforeProcessNode": 51 + forEach( 52 + queryAttributeOnThisOrChildren(parent, "ws-connect"), 53 + function (child) { 54 + ensureWebSocket(child); 55 + }, 56 + ); 57 + forEach( 58 + queryAttributeOnThisOrChildren(parent, "ws-send"), 59 + function (child) { 60 + ensureWebSocketSend(child); 61 + }, 62 + ); 63 + } 64 + }, 65 + }); 66 + 67 + function splitOnWhitespace(trigger) { 68 + return trigger.trim().split(/\s+/); 69 + } 70 + 71 + function getLegacyWebsocketURL(elt) { 72 + var legacySSEValue = api.getAttributeValue(elt, "hx-ws"); 73 + if (legacySSEValue) { 74 + var values = splitOnWhitespace(legacySSEValue); 75 + for (var i = 0; i < values.length; i++) { 76 + var value = values[i].split(/:(.+)/); 77 + if (value[0] === "connect") { 78 + return value[1]; 79 + } 80 + } 81 + } 82 + } 83 + 84 + /** 85 + * ensureWebSocket creates a new WebSocket on the designated element, using 86 + * the element's "ws-connect" attribute. 87 + * @param {HTMLElement} socketElt 88 + * @returns 89 + */ 90 + function ensureWebSocket(socketElt) { 91 + // If the element containing the WebSocket connection no longer exists, then 92 + // do not connect/reconnect the WebSocket. 93 + if (!api.bodyContains(socketElt)) { 94 + return; 95 + } 96 + 97 + // Get the source straight from the element's value 98 + var wssSource = api.getAttributeValue(socketElt, "ws-connect"); 99 + 100 + if (wssSource == null || wssSource === "") { 101 + var legacySource = getLegacyWebsocketURL(socketElt); 102 + if (legacySource == null) { 103 + return; 104 + } else { 105 + wssSource = legacySource; 106 + } 107 + } 108 + 109 + // Guarantee that the wssSource value is a fully qualified URL 110 + if (wssSource.indexOf("/") === 0) { 111 + var base_part = 112 + location.hostname + (location.port ? ":" + location.port : ""); 113 + if (location.protocol === "https:") { 114 + wssSource = "wss://" + base_part + wssSource; 115 + } else if (location.protocol === "http:") { 116 + wssSource = "ws://" + base_part + wssSource; 117 + } 118 + } 119 + 120 + var socketWrapper = createWebsocketWrapper(socketElt, function () { 121 + return htmx.createWebSocket(wssSource); 122 + }); 123 + 124 + socketWrapper.addEventListener("message", function (event) { 125 + if (maybeCloseWebSocketSource(socketElt)) { 126 + return; 127 + } 128 + 129 + var response = event.data; 130 + if ( 131 + !api.triggerEvent(socketElt, "htmx:wsBeforeMessage", { 132 + message: response, 133 + socketWrapper: socketWrapper.publicInterface, 134 + }) 135 + ) { 136 + return; 137 + } 138 + 139 + api.withExtensions(socketElt, function (extension) { 140 + response = extension.transformResponse(response, null, socketElt); 141 + }); 142 + 143 + var settleInfo = api.makeSettleInfo(socketElt); 144 + var fragment = api.makeFragment(response); 145 + 146 + if (fragment.children.length) { 147 + var children = Array.from(fragment.children); 148 + for (var i = 0; i < children.length; i++) { 149 + api.oobSwap( 150 + api.getAttributeValue(children[i], "hx-swap-oob") || "true", 151 + children[i], 152 + settleInfo, 153 + ); 154 + } 155 + } 156 + 157 + api.settleImmediately(settleInfo.tasks); 158 + api.triggerEvent(socketElt, "htmx:wsAfterMessage", { 159 + message: response, 160 + socketWrapper: socketWrapper.publicInterface, 161 + }); 162 + }); 163 + 164 + // Put the WebSocket into the HTML Element's custom data. 165 + api.getInternalData(socketElt).webSocket = socketWrapper; 166 + } 167 + 168 + /** 169 + * @typedef {Object} WebSocketWrapper 170 + * @property {WebSocket} socket 171 + * @property {Array<{message: string, sendElt: Element}>} messageQueue 172 + * @property {number} retryCount 173 + * @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state 174 + * @property {(message: string, sendElt: Element) => void} send 175 + * @property {(event: string, handler: Function) => void} addEventListener 176 + * @property {() => void} handleQueuedMessages 177 + * @property {() => void} init 178 + * @property {() => void} close 179 + */ 180 + /** 181 + * 182 + * @param socketElt 183 + * @param socketFunc 184 + * @returns {WebSocketWrapper} 185 + */ 186 + function createWebsocketWrapper(socketElt, socketFunc) { 187 + var wrapper = { 188 + socket: null, 189 + messageQueue: [], 190 + retryCount: 0, 191 + 192 + /** @type {Object<string, Function[]>} */ 193 + events: {}, 194 + 195 + addEventListener: function (event, handler) { 196 + if (this.socket) { 197 + this.socket.addEventListener(event, handler); 198 + } 199 + 200 + if (!this.events[event]) { 201 + this.events[event] = []; 202 + } 203 + 204 + this.events[event].push(handler); 205 + }, 206 + 207 + sendImmediately: function (message, sendElt) { 208 + if (!this.socket) { 209 + api.triggerErrorEvent(); 210 + } 211 + if ( 212 + !sendElt || 213 + api.triggerEvent(sendElt, "htmx:wsBeforeSend", { 214 + message, 215 + socketWrapper: this.publicInterface, 216 + }) 217 + ) { 218 + this.socket.send(message); 219 + sendElt && 220 + api.triggerEvent(sendElt, "htmx:wsAfterSend", { 221 + message, 222 + socketWrapper: this.publicInterface, 223 + }); 224 + } 225 + }, 226 + 227 + send: function (message, sendElt) { 228 + if (this.socket.readyState !== this.socket.OPEN) { 229 + this.messageQueue.push({ message, sendElt }); 230 + } else { 231 + this.sendImmediately(message, sendElt); 232 + } 233 + }, 234 + 235 + handleQueuedMessages: function () { 236 + while (this.messageQueue.length > 0) { 237 + var queuedItem = this.messageQueue[0]; 238 + if (this.socket.readyState === this.socket.OPEN) { 239 + this.sendImmediately(queuedItem.message, queuedItem.sendElt); 240 + this.messageQueue.shift(); 241 + } else { 242 + break; 243 + } 244 + } 245 + }, 246 + 247 + init: function () { 248 + if (this.socket && this.socket.readyState === this.socket.OPEN) { 249 + // Close discarded socket 250 + this.socket.close(); 251 + } 252 + 253 + // Create a new WebSocket and event handlers 254 + /** @type {WebSocket} */ 255 + var socket = socketFunc(); 256 + 257 + // The event.type detail is added for interface conformance with the 258 + // other two lifecycle events (open and close) so a single handler method 259 + // can handle them polymorphically, if required. 260 + api.triggerEvent(socketElt, "htmx:wsConnecting", { 261 + event: { type: "connecting" }, 262 + }); 263 + 264 + this.socket = socket; 265 + 266 + socket.onopen = function (e) { 267 + wrapper.retryCount = 0; 268 + api.triggerEvent(socketElt, "htmx:wsOpen", { 269 + event: e, 270 + socketWrapper: wrapper.publicInterface, 271 + }); 272 + wrapper.handleQueuedMessages(); 273 + }; 274 + 275 + socket.onclose = function (e) { 276 + // If socket should not be connected, stop further attempts to establish connection 277 + // If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause. 278 + if ( 279 + !maybeCloseWebSocketSource(socketElt) && 280 + [1006, 1012, 1013].indexOf(e.code) >= 0 281 + ) { 282 + var delay = getWebSocketReconnectDelay(wrapper.retryCount); 283 + setTimeout(function () { 284 + wrapper.retryCount += 1; 285 + wrapper.init(); 286 + }, delay); 287 + } 288 + 289 + // Notify client code that connection has been closed. Client code can inspect `event` field 290 + // to determine whether closure has been valid or abnormal 291 + api.triggerEvent(socketElt, "htmx:wsClose", { 292 + event: e, 293 + socketWrapper: wrapper.publicInterface, 294 + }); 295 + }; 296 + 297 + socket.onerror = function (e) { 298 + api.triggerErrorEvent(socketElt, "htmx:wsError", { 299 + error: e, 300 + socketWrapper: wrapper, 301 + }); 302 + maybeCloseWebSocketSource(socketElt); 303 + }; 304 + 305 + var events = this.events; 306 + Object.keys(events).forEach(function (k) { 307 + events[k].forEach(function (e) { 308 + socket.addEventListener(k, e); 309 + }); 310 + }); 311 + }, 312 + 313 + close: function () { 314 + this.socket.close(); 315 + }, 316 + }; 317 + 318 + wrapper.init(); 319 + 320 + wrapper.publicInterface = { 321 + send: wrapper.send.bind(wrapper), 322 + sendImmediately: wrapper.sendImmediately.bind(wrapper), 323 + queue: wrapper.messageQueue, 324 + }; 325 + 326 + return wrapper; 327 + } 328 + 329 + /** 330 + * ensureWebSocketSend attaches trigger handles to elements with 331 + * "ws-send" attribute 332 + * @param {HTMLElement} elt 333 + */ 334 + function ensureWebSocketSend(elt) { 335 + var legacyAttribute = api.getAttributeValue(elt, "hx-ws"); 336 + if (legacyAttribute && legacyAttribute !== "send") { 337 + return; 338 + } 339 + 340 + var webSocketParent = api.getClosestMatch(elt, hasWebSocket); 341 + processWebSocketSend(webSocketParent, elt); 342 + } 343 + 344 + /** 345 + * hasWebSocket function checks if a node has webSocket instance attached 346 + * @param {HTMLElement} node 347 + * @returns {boolean} 348 + */ 349 + function hasWebSocket(node) { 350 + return api.getInternalData(node).webSocket != null; 351 + } 352 + 353 + /** 354 + * processWebSocketSend adds event listeners to the <form> element so that 355 + * messages can be sent to the WebSocket server when the form is submitted. 356 + * @param {HTMLElement} socketElt 357 + * @param {HTMLElement} sendElt 358 + */ 359 + function processWebSocketSend(socketElt, sendElt) { 360 + var nodeData = api.getInternalData(sendElt); 361 + var triggerSpecs = api.getTriggerSpecs(sendElt); 362 + triggerSpecs.forEach(function (ts) { 363 + api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) { 364 + if (maybeCloseWebSocketSource(socketElt)) { 365 + return; 366 + } 367 + 368 + /** @type {WebSocketWrapper} */ 369 + var socketWrapper = api.getInternalData(socketElt).webSocket; 370 + var headers = api.getHeaders(sendElt, api.getTarget(sendElt)); 371 + var results = api.getInputValues(sendElt, "post"); 372 + var errors = results.errors; 373 + var rawParameters = Object.assign({}, results.values); 374 + var expressionVars = api.getExpressionVars(sendElt); 375 + var allParameters = api.mergeObjects(rawParameters, expressionVars); 376 + var filteredParameters = api.filterValues(allParameters, sendElt); 377 + 378 + var sendConfig = { 379 + parameters: filteredParameters, 380 + unfilteredParameters: allParameters, 381 + headers, 382 + errors, 383 + 384 + triggeringEvent: evt, 385 + messageBody: undefined, 386 + socketWrapper: socketWrapper.publicInterface, 387 + }; 388 + 389 + if (!api.triggerEvent(elt, "htmx:wsConfigSend", sendConfig)) { 390 + return; 391 + } 392 + 393 + if (errors && errors.length > 0) { 394 + api.triggerEvent(elt, "htmx:validation:halted", errors); 395 + return; 396 + } 397 + 398 + var body = sendConfig.messageBody; 399 + if (body === undefined) { 400 + var toSend = Object.assign({}, sendConfig.parameters); 401 + if (sendConfig.headers) { 402 + toSend.HEADERS = headers; 403 + } 404 + body = JSON.stringify(toSend); 405 + } 406 + 407 + socketWrapper.send(body, elt); 408 + 409 + if (evt && api.shouldCancel(evt, elt)) { 410 + evt.preventDefault(); 411 + } 412 + }); 413 + }); 414 + } 415 + 416 + /** 417 + * getWebSocketReconnectDelay is the default easing function for WebSocket reconnects. 418 + * @param {number} retryCount // The number of retries that have already taken place 419 + * @returns {number} 420 + */ 421 + function getWebSocketReconnectDelay(retryCount) { 422 + /** @type {"full-jitter" | ((retryCount:number) => number)} */ 423 + var delay = htmx.config.wsReconnectDelay; 424 + if (typeof delay === "function") { 425 + return delay(retryCount); 426 + } 427 + if (delay === "full-jitter") { 428 + var exp = Math.min(retryCount, 6); 429 + var maxDelay = 1000 * Math.pow(2, exp); 430 + return maxDelay * Math.random(); 431 + } 432 + 433 + logError( 434 + 'htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"', 435 + ); 436 + } 437 + 438 + /** 439 + * maybeCloseWebSocketSource checks to the if the element that created the WebSocket 440 + * still exists in the DOM. If NOT, then the WebSocket is closed and this function 441 + * returns TRUE. If the element DOES EXIST, then no action is taken, and this function 442 + * returns FALSE. 443 + * 444 + * @param {*} elt 445 + * @returns 446 + */ 447 + function maybeCloseWebSocketSource(elt) { 448 + if (!api.bodyContains(elt)) { 449 + api.getInternalData(elt).webSocket.close(); 450 + return true; 451 + } 452 + return false; 453 + } 454 + 455 + /** 456 + * createWebSocket is the default method for creating new WebSocket objects. 457 + * it is hoisted into htmx.createWebSocket to be overridden by the user, if needed. 458 + * 459 + * @param {string} url 460 + * @returns WebSocket 461 + */ 462 + function createWebSocket(url) { 463 + var sock = new WebSocket(url, []); 464 + sock.binaryType = htmx.config.wsBinaryType; 465 + return sock; 466 + } 467 + 468 + /** 469 + * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. 470 + * 471 + * @param {HTMLElement} elt 472 + * @param {string} attributeName 473 + */ 474 + function queryAttributeOnThisOrChildren(elt, attributeName) { 475 + var result = []; 476 + 477 + // If the parent element also contains the requested attribute, then add it to the results too. 478 + if ( 479 + api.hasAttribute(elt, attributeName) || 480 + api.hasAttribute(elt, "hx-ws") 481 + ) { 482 + result.push(elt); 483 + } 484 + 485 + // Search all child nodes that match the requested attribute 486 + elt 487 + .querySelectorAll( 488 + "[" + 489 + attributeName + 490 + "], [data-" + 491 + attributeName + 492 + "], [data-hx-ws], [hx-ws]", 493 + ) 494 + .forEach(function (node) { 495 + result.push(node); 496 + }); 497 + 498 + return result; 499 + } 500 + 501 + /** 502 + * @template T 503 + * @param {T[]} arr 504 + * @param {(T) => void} func 505 + */ 506 + function forEach(arr, func) { 507 + if (arr) { 508 + for (var i = 0; i < arr.length; i++) { 509 + func(arr[i]); 510 + } 511 + } 512 + } 513 + })();